refactor(plans): centralize local view state

This commit is contained in:
root 2026-05-06 20:13:22 +00:00
parent ac17525124
commit 5d5c1ed2bc
3 changed files with 300 additions and 129 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useReducer, useRef, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Pencil, RefreshCw, Trash2 } from 'lucide-react'; import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
@ -17,23 +17,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../co
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
import { PageHeader } from '../components/ui/page-header'; import { PageHeader } from '../components/ui/page-header';
import { Select } from '../components/ui/select'; import { Select } from '../components/ui/select';
import {
type SimpleSide = 'buy' | 'sell'; DEFAULT_TRADE_PLANS_UI_STATE,
type TriggerMode = 'dollar' | 'percent'; reduceTradePlansUiState,
type MarketPriceSource,
type SimpleSetupDraft = { type SimpleSetupDraft,
symbol: string; type SimpleSide,
side: SimpleSide; type TriggerMode,
sizingMode: 'quantity' | 'amount'; } from './tradePlansState';
quantity: string;
amountUsd: string;
currentMarketPrice: string;
dropMode: TriggerMode;
dropValue: string;
profitMode: TriggerMode;
profitValue: string;
notes: string;
};
type SimpleHolding = { type SimpleHolding = {
symbol: string; symbol: string;
@ -53,8 +44,6 @@ type SimpleRuntimeSnapshot = {
type SimpleOperationalEvent = NonNullable<ReturnType<typeof useAppContext>['botState']['operationalEvents']>[number]; type SimpleOperationalEvent = NonNullable<ReturnType<typeof useAppContext>['botState']['operationalEvents']>[number];
type MarketPriceSource = 'live' | 'latest_close' | 'reference_price' | null;
const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile'; const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase(); const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
const SIMPLE_SYMBOL_DATALIST_ID = 'simple-supported-symbols'; const SIMPLE_SYMBOL_DATALIST_ID = 'simple-supported-symbols';
@ -72,20 +61,6 @@ const COMMON_SIMPLE_SYMBOLS = [
'SOL/USD', 'SOL/USD',
]; ];
const DEFAULT_DRAFT: SimpleSetupDraft = {
symbol: '',
side: 'buy',
sizingMode: 'quantity',
quantity: '',
amountUsd: '',
currentMarketPrice: '',
dropMode: 'percent',
dropValue: '',
profitMode: 'percent',
profitValue: '',
notes: '',
};
function parsePositiveNumber(value: string): number | null { function parsePositiveNumber(value: string): number | null {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return null; if (!trimmed) return null;
@ -620,22 +595,26 @@ export function SimpleView() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [profiles, setProfiles] = useState<TradeProfilePayload[]>([]); const [profiles, setProfiles] = useState<TradeProfilePayload[]>([]);
const [savedSetups, setSavedSetups] = useState<ManualEntryPayload[]>([]); const [savedSetups, setSavedSetups] = useState<ManualEntryPayload[]>([]);
const [editingSetupId, setEditingSetupId] = useState<string | null>(null); const [uiState, dispatch] = useReducer(reduceTradePlansUiState, DEFAULT_TRADE_PLANS_UI_STATE);
const [draft, setDraft] = useState<SimpleSetupDraft>(DEFAULT_DRAFT);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loadingPrice, setLoadingPrice] = useState(false); const [loadingPrice, setLoadingPrice] = useState(false);
const [marketPriceSource, setMarketPriceSource] = useState<MarketPriceSource>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState<string | null>(null);
const [focusedSetupId, setFocusedSetupId] = useState<string | null>(null);
const marketPriceRequestSymbolRef = useRef<string>(''); const marketPriceRequestSymbolRef = useRef<string>('');
const consumedPrefillKeyRef = useRef<string>(''); const consumedPrefillKeyRef = useRef<string>('');
const consumedSetupFocusKeyRef = useRef<string>(''); const consumedSetupFocusKeyRef = useRef<string>('');
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({}); const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
const focusedSetupTimerRef = useRef<number | null>(null); const focusedSetupTimerRef = useRef<number | null>(null);
const {
editingSetupId,
draft,
marketPriceSource,
copiedKey,
message,
error,
selectedHoldingTradeId,
focusedSetupId,
} = uiState;
const normalizedSymbol = draft.symbol.trim().toUpperCase(); const normalizedSymbol = draft.symbol.trim().toUpperCase();
const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {}; const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {};
const livePrice = normalizedSymbol ? Number(symbolState?.[normalizedSymbol]?.price || 0) : 0; const livePrice = normalizedSymbol ? Number(symbolState?.[normalizedSymbol]?.price || 0) : 0;
@ -728,7 +707,7 @@ export function SimpleView() {
setSavedSetups(normalizeSimpleEntries(entryRows)); setSavedSetups(normalizeSimpleEntries(entryRows));
} catch (err: any) { } catch (err: any) {
if (cancelled) return; if (cancelled) return;
setError(err?.message ?? 'Failed to load Simple setups'); dispatch({ type: 'set-error', value: err?.message ?? 'Failed to load Simple setups' });
} }
} }
@ -740,12 +719,10 @@ export function SimpleView() {
useEffect(() => { useEffect(() => {
if (!livePrice) return; if (!livePrice) return;
setMarketPriceSource('live'); dispatch({ type: 'set-market-price-source', value: 'live' });
setDraft((prev) => ( if (draft.currentMarketPrice !== livePrice.toFixed(4)) {
prev.currentMarketPrice === livePrice.toFixed(4) dispatch({ type: 'set-draft-field', key: 'currentMarketPrice', value: livePrice.toFixed(4) });
? prev }
: { ...prev, currentMarketPrice: livePrice.toFixed(4) }
));
}, [livePrice]); }, [livePrice]);
useEffect(() => { useEffect(() => {
@ -777,24 +754,21 @@ export function SimpleView() {
function updateDraft<K extends keyof SimpleSetupDraft>(key: K, value: SimpleSetupDraft[K]) { function updateDraft<K extends keyof SimpleSetupDraft>(key: K, value: SimpleSetupDraft[K]) {
if (key === 'side' && value === 'buy') { if (key === 'side' && value === 'buy') {
setSelectedHoldingTradeId(null); dispatch({ type: 'set-selected-holding-trade-id', value: null });
} }
if (key === 'symbol' && draft.side === 'sell') { if (key === 'symbol' && draft.side === 'sell') {
setSelectedHoldingTradeId(null); dispatch({ type: 'set-selected-holding-trade-id', value: null });
} }
setDraft((prev) => ({ ...prev, [key]: value })); dispatch({ type: 'set-draft-field', key, value });
} }
function applyHoldingToDraft(holding: SimpleHolding) { function applyHoldingToDraft(holding: SimpleHolding) {
setMarketPriceSource(null); dispatch({
setSelectedHoldingTradeId(holding.tradeId || null); type: 'apply-holding',
setDraft((prev) => ({ tradeId: holding.tradeId || null,
...prev,
side: 'sell',
symbol: holding.symbol, symbol: holding.symbol,
quantity: String(holding.size), quantity: String(holding.size),
currentMarketPrice: '', });
}));
} }
useEffect(() => { useEffect(() => {
@ -823,8 +797,8 @@ export function SimpleView() {
if (selected) { if (selected) {
applyHoldingToDraft(selected); applyHoldingToDraft(selected);
setMessage(`Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.`); dispatch({ type: 'set-message', value: `Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.` });
setError(null); dispatch({ type: 'set-error', value: null });
} }
consumedPrefillKeyRef.current = prefillKey; consumedPrefillKeyRef.current = prefillKey;
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
@ -842,14 +816,14 @@ export function SimpleView() {
if (!targetEntry) return; if (!targetEntry) return;
setFocusedSetupId(requestedSetupId); dispatch({ type: 'set-focused-setup-id', value: requestedSetupId });
setMessage(`Focused saved plan for ${targetEntry.symbol}.`); dispatch({ type: 'set-message', value: `Focused saved plan for ${targetEntry.symbol}.` });
setError(null); dispatch({ type: 'set-error', value: null });
if (focusedSetupTimerRef.current !== null) { if (focusedSetupTimerRef.current !== null) {
window.clearTimeout(focusedSetupTimerRef.current); window.clearTimeout(focusedSetupTimerRef.current);
} }
focusedSetupTimerRef.current = window.setTimeout(() => { focusedSetupTimerRef.current = window.setTimeout(() => {
setFocusedSetupId((prev) => (prev === requestedSetupId ? null : prev)); dispatch({ type: 'set-focused-setup-id', value: null });
focusedSetupTimerRef.current = null; focusedSetupTimerRef.current = null;
}, 2200); }, 2200);
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@ -870,12 +844,12 @@ export function SimpleView() {
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
const key = `${kind}:${value}`; const key = `${kind}:${value}`;
setCopiedKey(key); dispatch({ type: 'set-copied-key', value: key });
window.setTimeout(() => { window.setTimeout(() => {
setCopiedKey((prev) => (prev === key ? null : prev)); dispatch({ type: 'set-copied-key', value: null });
}, 1200); }, 1200);
} catch { } catch {
setError(`Failed to copy ${kind} ID`); dispatch({ type: 'set-error', value: `Failed to copy ${kind} ID` });
} }
} }
@ -898,7 +872,7 @@ export function SimpleView() {
} }
function setMarketPriceValue(value: string, source: MarketPriceSource) { function setMarketPriceValue(value: string, source: MarketPriceSource) {
setMarketPriceSource(source); dispatch({ type: 'set-market-price-source', value: source });
updateDraft('currentMarketPrice', value); updateDraft('currentMarketPrice', value);
} }
@ -908,8 +882,7 @@ export function SimpleView() {
marketPriceRequestSymbolRef.current = requestSymbol; marketPriceRequestSymbolRef.current = requestSymbol;
setLoadingPrice(true); setLoadingPrice(true);
if (!options?.silent) { if (!options?.silent) {
setError(null); dispatch({ type: 'clear-feedback' });
setMessage(null);
} }
try { try {
@ -937,7 +910,7 @@ export function SimpleView() {
} }
} catch (err: any) { } catch (err: any) {
if (!options?.silent && marketPriceRequestSymbolRef.current === requestSymbol) { if (!options?.silent && marketPriceRequestSymbolRef.current === requestSymbol) {
setError(err?.message ?? 'Failed to load market data'); dispatch({ type: 'set-error', value: err?.message ?? 'Failed to load market data' });
} }
} finally { } finally {
if (marketPriceRequestSymbolRef.current === requestSymbol) { if (marketPriceRequestSymbolRef.current === requestSymbol) {
@ -949,8 +922,7 @@ export function SimpleView() {
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setSubmitting(true); setSubmitting(true);
setError(null); dispatch({ type: 'clear-feedback' });
setMessage(null);
try { try {
const existingEntry = editingSetupId const existingEntry = editingSetupId
@ -965,37 +937,34 @@ export function SimpleView() {
if (editingSetupId) { if (editingSetupId) {
await updateManualEntry(editingSetupId, payload); await updateManualEntry(editingSetupId, payload);
setMessage(`Updated ${normalizedSymbol} Simple setup.`); dispatch({ type: 'set-message', value: `Updated ${normalizedSymbol} Simple setup.` });
} else { } else {
await createManualEntry({ await createManualEntry({
...payload, ...payload,
stock_instance_id: crypto.randomUUID(), stock_instance_id: crypto.randomUUID(),
}); });
setMessage(`Saved ${normalizedSymbol} Simple setup.`); dispatch({ type: 'set-message', value: `Saved ${normalizedSymbol} Simple setup.` });
} }
await refreshSetupList(); await refreshSetupList();
setEditingSetupId(null); dispatch({
setSelectedHoldingTradeId(null); type: 'reset-form',
setMarketPriceSource(draft.currentMarketPrice ? marketPriceSource : null);
setDraft({
...DEFAULT_DRAFT,
currentMarketPrice: draft.currentMarketPrice, currentMarketPrice: draft.currentMarketPrice,
marketPriceSource: draft.currentMarketPrice ? marketPriceSource : null,
}); });
} catch (err: any) { } catch (err: any) {
setError(err?.message ?? 'Failed to save Simple setup'); dispatch({ type: 'set-error', value: err?.message ?? 'Failed to save Simple setup' });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
} }
function handleEdit(entry: ManualEntryPayload) { function handleEdit(entry: ManualEntryPayload) {
setEditingSetupId(String(entry.stock_instance_id || '')); dispatch({ type: 'set-editing-setup-id', value: String(entry.stock_instance_id || '') });
setSelectedHoldingTradeId(String(entry.linked_trade_id || '').trim() || null); dispatch({ type: 'set-selected-holding-trade-id', value: String(entry.linked_trade_id || '').trim() || null });
setMarketPriceSource(inferMarketPriceSourceFromEntry(entry)); dispatch({ type: 'set-market-price-source', value: inferMarketPriceSourceFromEntry(entry) });
setDraft(buildDraftFromEntry(entry)); dispatch({ type: 'replace-draft', draft: buildDraftFromEntry(entry) });
setMessage(null); dispatch({ type: 'clear-feedback' });
setError(null);
} }
async function handleDelete(entryId: string) { async function handleDelete(entryId: string) {
@ -1003,22 +972,18 @@ export function SimpleView() {
try { try {
await deleteManualEntry(entryId); await deleteManualEntry(entryId);
if (editingSetupId === entryId) { if (editingSetupId === entryId) {
setEditingSetupId(null); dispatch({ type: 'reset-form', currentMarketPrice: '', marketPriceSource: null });
setSelectedHoldingTradeId(null);
setMarketPriceSource(null);
setDraft(DEFAULT_DRAFT);
} }
await refreshSetupList(); await refreshSetupList();
} catch (err: any) { } catch (err: any) {
setError(err?.message ?? 'Failed to delete Simple setup'); dispatch({ type: 'set-error', value: err?.message ?? 'Failed to delete Simple setup' });
} }
} }
async function handleConvertToLongTerm(entry: ManualEntryPayload) { async function handleConvertToLongTerm(entry: ManualEntryPayload) {
const entryId = String(entry.stock_instance_id || ''); const entryId = String(entry.stock_instance_id || '');
if (!entryId) return; if (!entryId) return;
setError(null); dispatch({ type: 'clear-feedback' });
setMessage(null);
try { try {
await updateSavedSetup(entryId, (current) => ({ await updateSavedSetup(entryId, (current) => ({
...current, ...current,
@ -1027,17 +992,16 @@ export function SimpleView() {
status: 'simple_bought', status: 'simple_bought',
active: true, active: true,
})); }));
setMessage(`${String(entry.symbol || '').trim().toUpperCase()} is now treated as a long-term hold. Automated exit monitoring is paused.`); dispatch({ type: 'set-message', value: `${String(entry.symbol || '').trim().toUpperCase()} is now treated as a long-term hold. Automated exit monitoring is paused.` });
} catch (err: any) { } catch (err: any) {
setError(err?.message ?? 'Failed to convert setup to long-term mode'); dispatch({ type: 'set-error', value: err?.message ?? 'Failed to convert setup to long-term mode' });
} }
} }
async function handleResumeExitManagement(entry: ManualEntryPayload) { async function handleResumeExitManagement(entry: ManualEntryPayload) {
const entryId = String(entry.stock_instance_id || ''); const entryId = String(entry.stock_instance_id || '');
if (!entryId) return; if (!entryId) return;
setError(null); dispatch({ type: 'clear-feedback' });
setMessage(null);
try { try {
await updateSavedSetup(entryId, (current) => ({ await updateSavedSetup(entryId, (current) => ({
...current, ...current,
@ -1046,9 +1010,9 @@ export function SimpleView() {
status: 'simple_bought', status: 'simple_bought',
active: true, active: true,
})); }));
setMessage(`${String(entry.symbol || '').trim().toUpperCase()} is back under short-term exit management.`); dispatch({ type: 'set-message', value: `${String(entry.symbol || '').trim().toUpperCase()} is back under short-term exit management.` });
} catch (err: any) { } catch (err: any) {
setError(err?.message ?? 'Failed to resume short-term exit management'); dispatch({ type: 'set-error', value: err?.message ?? 'Failed to resume short-term exit management' });
} }
} }
@ -1072,15 +1036,11 @@ export function SimpleView() {
<Button <Button
type="button" type="button"
onClick={() => { onClick={() => {
setEditingSetupId(null); dispatch({
setSelectedHoldingTradeId(null); type: 'reset-form',
setMarketPriceSource(draft.currentMarketPrice ? marketPriceSource : null);
setDraft({
...DEFAULT_DRAFT,
currentMarketPrice: draft.currentMarketPrice, currentMarketPrice: draft.currentMarketPrice,
marketPriceSource: draft.currentMarketPrice ? marketPriceSource : null,
}); });
setMessage(null);
setError(null);
}} }}
variant="outline" variant="outline"
size="sm" size="sm"
@ -1096,10 +1056,9 @@ export function SimpleView() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setError(null); dispatch({ type: 'clear-feedback' });
setMessage(null); dispatch({ type: 'set-selected-holding-trade-id', value: null });
setSelectedHoldingTradeId(null); updateDraft('side', 'buy');
setDraft((prev) => ({ ...prev, side: 'buy' }));
}} }}
className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${ className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${
draft.side === 'buy' draft.side === 'buy'
@ -1114,12 +1073,11 @@ export function SimpleView() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setError(null); dispatch({ type: 'clear-feedback' });
setMessage(null);
if (availableSellHoldings.length > 0) { if (availableSellHoldings.length > 0) {
applyHoldingToDraft(availableSellHoldings[0]); applyHoldingToDraft(availableSellHoldings[0]);
} else { } else {
setDraft((prev) => ({ ...prev, side: 'sell' })); updateDraft('side', 'sell');
} }
}} }}
className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${ className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${
@ -1167,15 +1125,18 @@ export function SimpleView() {
<Input <Input
value={draft.symbol} value={draft.symbol}
onChange={(e) => { onChange={(e) => {
setMarketPriceSource(null); dispatch({ type: 'set-market-price-source', value: null });
if (draft.side === 'sell') { if (draft.side === 'sell') {
setSelectedHoldingTradeId(null); dispatch({ type: 'set-selected-holding-trade-id', value: null });
} }
setDraft((prev) => ({ dispatch({
...prev, type: 'replace-draft',
symbol: e.target.value.toUpperCase(), draft: {
currentMarketPrice: '', ...draft,
})); symbol: e.target.value.toUpperCase(),
currentMarketPrice: '',
},
});
}} }}
list={SIMPLE_SYMBOL_DATALIST_ID} list={SIMPLE_SYMBOL_DATALIST_ID}
placeholder="AAPL" placeholder="AAPL"
@ -1200,15 +1161,18 @@ export function SimpleView() {
key={symbol} key={symbol}
type="button" type="button"
onClick={() => { onClick={() => {
setMarketPriceSource(null); dispatch({ type: 'set-market-price-source', value: null });
if (draft.side === 'sell') { if (draft.side === 'sell') {
setSelectedHoldingTradeId(null); dispatch({ type: 'set-selected-holding-trade-id', value: null });
} }
setDraft((prev) => ({ dispatch({
...prev, type: 'replace-draft',
symbol, draft: {
currentMarketPrice: '', ...draft,
})); symbol,
currentMarketPrice: '',
},
});
}} }}
className={`rounded-full border px-3 py-1 text-[11px] font-semibold transition ${ className={`rounded-full border px-3 py-1 text-[11px] font-semibold transition ${
symbol === normalizedSymbol symbol === normalizedSymbol

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_TRADE_PLANS_UI_STATE,
reduceTradePlansUiState,
} from './tradePlansState';
describe('tradePlansState reducer', () => {
it('applies a holding and switches the draft into sell management mode', () => {
const next = reduceTradePlansUiState(DEFAULT_TRADE_PLANS_UI_STATE, {
type: 'apply-holding',
tradeId: 'TRD-1',
symbol: 'AAPL',
quantity: '10',
});
expect(next.selectedHoldingTradeId).toBe('TRD-1');
expect(next.marketPriceSource).toBeNull();
expect(next.draft.side).toBe('sell');
expect(next.draft.symbol).toBe('AAPL');
expect(next.draft.quantity).toBe('10');
expect(next.draft.currentMarketPrice).toBe('');
});
it('resets the editor while preserving the current market price context', () => {
const next = reduceTradePlansUiState({
...DEFAULT_TRADE_PLANS_UI_STATE,
editingSetupId: 'setup-1',
selectedHoldingTradeId: 'TRD-1',
message: 'hello',
error: 'bad',
marketPriceSource: 'live',
draft: {
...DEFAULT_TRADE_PLANS_UI_STATE.draft,
symbol: 'BTC/USD',
currentMarketPrice: '81234.0000',
notes: 'keep',
},
}, {
type: 'reset-form',
currentMarketPrice: '81234.0000',
marketPriceSource: 'live',
});
expect(next.editingSetupId).toBeNull();
expect(next.selectedHoldingTradeId).toBeNull();
expect(next.message).toBeNull();
expect(next.error).toBeNull();
expect(next.marketPriceSource).toBe('live');
expect(next.draft.symbol).toBe('');
expect(next.draft.currentMarketPrice).toBe('81234.0000');
expect(next.draft.notes).toBe('');
});
});

View File

@ -0,0 +1,154 @@
export type SimpleSide = 'buy' | 'sell';
export type TriggerMode = 'dollar' | 'percent';
export type MarketPriceSource = 'live' | 'latest_close' | 'reference_price' | null;
export type SimpleSetupDraft = {
symbol: string;
side: SimpleSide;
sizingMode: 'quantity' | 'amount';
quantity: string;
amountUsd: string;
currentMarketPrice: string;
dropMode: TriggerMode;
dropValue: string;
profitMode: TriggerMode;
profitValue: string;
notes: string;
};
export const DEFAULT_TRADE_PLAN_DRAFT: SimpleSetupDraft = {
symbol: '',
side: 'buy',
sizingMode: 'quantity',
quantity: '',
amountUsd: '',
currentMarketPrice: '',
dropMode: 'percent',
dropValue: '',
profitMode: 'percent',
profitValue: '',
notes: '',
};
export type TradePlansUiState = {
editingSetupId: string | null;
draft: SimpleSetupDraft;
marketPriceSource: MarketPriceSource;
copiedKey: string | null;
message: string | null;
error: string | null;
selectedHoldingTradeId: string | null;
focusedSetupId: string | null;
};
export const DEFAULT_TRADE_PLANS_UI_STATE: TradePlansUiState = {
editingSetupId: null,
draft: DEFAULT_TRADE_PLAN_DRAFT,
marketPriceSource: null,
copiedKey: null,
message: null,
error: null,
selectedHoldingTradeId: null,
focusedSetupId: null,
};
export type TradePlansUiAction =
| { type: 'set-draft-field'; key: keyof SimpleSetupDraft; value: SimpleSetupDraft[keyof SimpleSetupDraft] }
| { type: 'replace-draft'; draft: SimpleSetupDraft }
| { type: 'set-editing-setup-id'; value: string | null }
| { type: 'set-market-price-source'; value: MarketPriceSource }
| { type: 'set-copied-key'; value: string | null }
| { type: 'set-message'; value: string | null }
| { type: 'set-error'; value: string | null }
| { type: 'set-selected-holding-trade-id'; value: string | null }
| { type: 'set-focused-setup-id'; value: string | null }
| { type: 'clear-feedback' }
| { type: 'reset-form'; currentMarketPrice: string; marketPriceSource: MarketPriceSource }
| { type: 'apply-holding'; tradeId: string | null; symbol: string; quantity: string };
export function reduceTradePlansUiState(state: TradePlansUiState, action: TradePlansUiAction): TradePlansUiState {
switch (action.type) {
case 'set-draft-field':
return {
...state,
draft: {
...state.draft,
[action.key]: action.value,
},
};
case 'replace-draft':
return {
...state,
draft: action.draft,
};
case 'set-editing-setup-id':
return {
...state,
editingSetupId: action.value,
};
case 'set-market-price-source':
return {
...state,
marketPriceSource: action.value,
};
case 'set-copied-key':
return {
...state,
copiedKey: action.value,
};
case 'set-message':
return {
...state,
message: action.value,
};
case 'set-error':
return {
...state,
error: action.value,
};
case 'set-selected-holding-trade-id':
return {
...state,
selectedHoldingTradeId: action.value,
};
case 'set-focused-setup-id':
return {
...state,
focusedSetupId: action.value,
};
case 'clear-feedback':
return {
...state,
message: null,
error: null,
};
case 'reset-form':
return {
...state,
editingSetupId: null,
selectedHoldingTradeId: null,
message: null,
error: null,
marketPriceSource: action.marketPriceSource,
draft: {
...DEFAULT_TRADE_PLAN_DRAFT,
currentMarketPrice: action.currentMarketPrice,
},
};
case 'apply-holding':
return {
...state,
selectedHoldingTradeId: action.tradeId,
marketPriceSource: null,
draft: {
...state.draft,
side: 'sell',
symbol: action.symbol,
quantity: action.quantity,
currentMarketPrice: '',
},
};
default:
return state;
}
}