diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 5ed35da..267d060 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -47,6 +47,15 @@ import { type BacktestRunTimeframeChoice, } from '../utils/backtestRunTimeframe'; import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals'; +import { + loadStrategyListSort, + saveStrategyListSort, + sortStrategies, + strategyListSortLabel, + toggleStrategyListSort, + type StrategyListSort, + type StrategyListSortField, +} from '../utils/strategyEvaluationListSort'; import type { OHLCVBar } from '../types'; import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal'; import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal'; @@ -132,6 +141,7 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { const [marketSearchOpen, setMarketSearchOpen] = useState(false); const [strategies, setStrategies] = useState([]); const [strategySearch, setStrategySearch] = useState(''); + const [strategyListSort, setStrategyListSort] = useState(loadStrategyListSort); const [selectedStrategyId, setSelectedStrategyId] = useState(null); const [selectedStrategy, setSelectedStrategy] = useState(null); @@ -416,6 +426,19 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { ); }, [strategies, strategySearch]); + const sortedFilteredStrategies = useMemo( + () => sortStrategies(filteredStrategies, strategyListSort), + [filteredStrategies, strategyListSort], + ); + + const handleStrategyListSort = useCallback((field: StrategyListSortField) => { + setStrategyListSort(prev => { + const next = toggleStrategyListSort(prev, field); + saveStrategyListSort(next); + return next; + }); + }, []); + const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null; useEffect(() => { @@ -953,11 +976,31 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { )} +
+ + +
- {filteredStrategies.length === 0 ? ( + {sortedFilteredStrategies.length === 0 ? (

저장된 전략이 없습니다

) : ( - filteredStrategies.map(s => { + sortedFilteredStrategies.map(s => { const name = repairUtf8Mojibake(s.name ?? `전략 #${s.id}`); const active = selectedStrategyId === s.id; return ( diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index 4324c58..49ee628 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -327,6 +327,10 @@ min-width: 0; } +.seval-strategy-list-sort { + padding: 0 10px 6px; +} + .seval-strategy-add-btn { flex-shrink: 0; display: flex; diff --git a/frontend/src/utils/strategyEvaluationListSort.ts b/frontend/src/utils/strategyEvaluationListSort.ts new file mode 100644 index 0000000..4226513 --- /dev/null +++ b/frontend/src/utils/strategyEvaluationListSort.ts @@ -0,0 +1,81 @@ +import type { StrategyDto } from './backendApi'; +import { repairUtf8Mojibake } from './textEncoding'; + +export type StrategyListSortField = 'name' | 'createdAt'; +export type StrategyListSortDir = 'asc' | 'desc'; + +export interface StrategyListSort { + field: StrategyListSortField; + dir: StrategyListSortDir; +} + +const STORAGE_KEY = 'seval-strategy-list-sort'; + +const DEFAULT_SORT: StrategyListSort = { field: 'createdAt', dir: 'desc' }; + +export function loadStrategyListSort(): StrategyListSort { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_SORT; + const parsed = JSON.parse(raw) as Partial; + const field = parsed.field === 'name' || parsed.field === 'createdAt' ? parsed.field : DEFAULT_SORT.field; + const dir = parsed.dir === 'asc' || parsed.dir === 'desc' ? parsed.dir : DEFAULT_SORT.dir; + return { field, dir }; + } catch { + return DEFAULT_SORT; + } +} + +export function saveStrategyListSort(sort: StrategyListSort): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(sort)); + } catch { + /* ignore quota */ + } +} + +/** 같은 필드 재클릭 시 방향 토글, 다른 필드는 해당 필드 기본 방향 */ +export function toggleStrategyListSort( + prev: StrategyListSort, + field: StrategyListSortField, +): StrategyListSort { + if (prev.field !== field) { + return field === 'name' + ? { field: 'name', dir: 'asc' } + : { field: 'createdAt', dir: 'desc' }; + } + return { field, dir: prev.dir === 'asc' ? 'desc' : 'asc' }; +} + +function strategyCreatedTimeMs(s: StrategyDto): number { + if (s.createdAt) { + const t = Date.parse(s.createdAt); + if (Number.isFinite(t)) return t; + } + return s.id ?? 0; +} + +export function sortStrategies( + list: StrategyDto[], + sort: StrategyListSort, +): StrategyDto[] { + const mul = sort.dir === 'asc' ? 1 : -1; + return [...list].sort((a, b) => { + if (sort.field === 'name') { + const na = repairUtf8Mojibake(a.name ?? ''); + const nb = repairUtf8Mojibake(b.name ?? ''); + return mul * na.localeCompare(nb, 'ko'); + } + return mul * (strategyCreatedTimeMs(a) - strategyCreatedTimeMs(b)); + }); +} + +export function strategyListSortLabel(sort: StrategyListSort, field: StrategyListSortField): string { + if (sort.field !== field) { + return field === 'name' ? '이름순' : '최신순'; + } + if (field === 'name') { + return sort.dir === 'asc' ? '이름순 ↑' : '이름순 ↓'; + } + return sort.dir === 'desc' ? '최신순 ↓' : '최신순 ↑'; +}