refactor(plans): centralize local view state
This commit is contained in:
parent
ac17525124
commit
5d5c1ed2bc
@ -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
|
||||||
|
|||||||
53
web/src/views/tradePlansState.test.ts
Normal file
53
web/src/views/tradePlansState.test.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
154
web/src/views/tradePlansState.ts
Normal file
154
web/src/views/tradePlansState.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user