/** * useAppSettings * * 앱 전역 차트 기본 설정을 DB 와 동기화하는 훅. * * 관리 항목 (하드코딩 대체): * - defaultSymbol : 기본 종목 (DEFAULT_STATE.symbol) * - defaultTimeframe : 기본 타임프레임 (DEFAULT_STATE.timeframe) * - defaultChartType : 기본 차트 타입 * - (테마는 DB 미사용 — localStorage, see localThemeStorage) * - defaultLogScale : 기본 로그 스케일 * - defaultLayoutId : 기본 레이아웃 ID * - mainChartStyle : 캔들 색상 (DEFAULT_MAIN_CHART_STYLE) * - syncOptions : 멀티차트 동기화 옵션 (DEFAULT_SYNC) * * 사용 예: * ```tsx * const { settings, isLoaded, save } = useAppSettings(); * * // 기본 심볼 사용 * const symbol = settings.defaultSymbol ?? 'KRW-BTC'; * * // 테마 변경 시 saveLocalTheme() (App.tsx) * ``` */ import { useCallback, useEffect, useRef, useState } from 'react'; import { hasRegisteredUser, loadAppSettings, loadStrategies, saveAppSettings, type AppSettingsDto, } from '../utils/backendApi'; import type { Theme, ChartType, Timeframe, MainChartStyle } from '../types'; import { resolveChartLegendOptions } from '../types/chartLegend'; import type { ChartLegendVisibility } from '../types/chartLegend'; import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage'; import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes'; import { DEFAULT_CHART_TIME_FORMAT, normalizeChartTimeFormat, setChartTimeFormat, } from '../utils/chartTimeFormat'; import { DEFAULT_TRADE_ALERT_TIME_FORMAT, setTradeAlertTimeFormat, } from '../utils/tradeAlertTimeFormat'; import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone'; import { clampVirtualTargetMax, DEFAULT_VIRTUAL_TARGET_MAX, } from '../utils/virtualTargetLimits'; import { resolveTrendSearchAppSettings, type TrendSearchAppSettings, } from '../utils/trendSearchAppSettings'; import { resolveChartPaneSeparatorOptions, type ChartPaneSeparatorOptions, } from '../types/chartPaneSeparator'; import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate'; import { loadLocalTheme } from '../utils/localThemeStorage'; // 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지 let _cache: AppSettingsDto | null = null; let _loadPromise: Promise | null = null; /** invalidate 이후 완료된 로드만 캐시에 반영 */ let _loadGeneration = 0; type AppSettingsListener = () => void; const _listeners = new Set(); function notifyAppSettingsListeners() { _listeners.forEach(fn => fn()); } /** 훅 외부에서 최신 캐시 읽기 (추세검색 설정 병합 등) */ export function getAppSettingsCache(): AppSettingsDto { return _cache ?? {}; } /** 낙관적 캐시 패치 — _cache 가 null 일 때도 다음 로드 전까지 임시 보관 */ export function patchAppSettingsCache(patch: Partial): void { if (_cache !== null) { Object.assign(_cache, patch); } // _cache 가 null 이면 현재 로드 중이므로 건너뜀 (로드 완료 후 DB 저장값으로 덮어씌워짐) } export function subscribeAppSettings(listener: AppSettingsListener): () => void { _listeners.add(listener); return () => _listeners.delete(listener); } function ensureLoaded(): Promise { if (_cache !== null) return Promise.resolve(_cache); if (_loadPromise) return _loadPromise; const generation = _loadGeneration; _loadPromise = loadAppSettings().then(async data => { if (generation !== _loadGeneration) { _loadPromise = null; return _cache ?? {}; } _cache = data ?? {}; const migrated = migrateLocalStorageToUiPreferences(_cache); if (migrated?.changed) { try { const saved = await saveAppSettings({ uiPreferences: migrated.uiPreferences }); if (generation === _loadGeneration) { _cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences }; } } catch (e) { console.warn('[useAppSettings] uiPreferences 마이그레이션 저장 실패:', e); if (generation === _loadGeneration) { _cache = { ..._cache, uiPreferences: migrated.uiPreferences }; } } } if (hasRegisteredUser()) { try { await loadStrategies(); } catch (e) { console.warn('[useAppSettings] strategies preload failed', e); } } _loadPromise = null; if (generation === _loadGeneration) { notifyAppSettingsListeners(); } // loadStrategies() 등 await 중 invalidateAppSettingsCache()가 호출되면 // _cache 가 null 로 초기화될 수 있으므로 ?? {} 로 null 반환 방지 return _cache ?? {}; }); return _loadPromise; } export function invalidateAppSettingsCache() { _cache = null; _loadPromise = null; _loadGeneration += 1; } /** 서버에서 app-settings 재로드 후 캐시 갱신 (모바일 ↻ 동기화 등) */ export function reloadAppSettingsCache(): Promise { invalidateAppSettingsCache(); return ensureLoaded(); } // ───────────────────────────────────────────────────────────────────────────── /** DB 또는 fallback 기본값 접근 헬퍼 */ export function resolveAppDefaults(s: AppSettingsDto) { return { symbol: (s.defaultSymbol ?? 'KRW-BTC') as string, timeframe: (s.defaultTimeframe ?? '1D') as Timeframe, chartType: (s.defaultChartType ?? 'candlestick') as ChartType, theme: loadLocalTheme(), logScale: s.defaultLogScale ?? false, layoutId: s.defaultLayoutId ?? '1', mainChartStyle:(s.mainChartStyle as unknown as MainChartStyle | null) ?? DEFAULT_MAIN_CHART_STYLE, syncOptions: (s.syncOptions as unknown as SyncOptions | null) ?? DEFAULT_SYNC, btAutoPopup: s.btAutoPopup ?? true, btShowPrice: s.btShowPrice ?? true, chartCandleAreaPriceLabels: s.chartCandleAreaPriceLabels ?? s.chartSeriesPriceLabels ?? true, chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true, chartVolumeVisible: s.chartVolumeVisible ?? true, chartLiveReceiveHighlight: s.chartLiveReceiveHighlight ?? true, chartCrosshairInfoVisible: s.chartCrosshairInfoVisible ?? true, chartHoverToolbarVisible: s.chartHoverToolbarVisible ?? true, chartLegendOptions: resolveChartLegendOptions( s.chartLegendOptions as Partial | null | undefined, ), chartPaneSeparator: resolveChartPaneSeparatorOptions( s.chartPaneSeparator as Partial | null | undefined, loadLocalTheme(), ), tradeAlertPopup: s.tradeAlertPopup ?? true, tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true, tradeAlertSound: s.tradeAlertSound ?? 'bell', tradeAlertPopupPosition: s.tradeAlertPopupPosition ?? 'right', tradeAlertPopupLayout: s.tradeAlertPopupLayout ?? 'stack', tradeAlertPopupGridCols: s.tradeAlertPopupGridCols ?? 2, verificationIssueNotify: s.verificationIssueNotify ?? true, liveStrategyCheck: s.liveStrategyCheck ?? false, liveStrategyId: s.liveStrategyId ?? null, liveExecutionType: s.liveExecutionType ?? 'CANDLE_CLOSE', livePositionMode: s.livePositionMode ?? 'LONG_ONLY', paperTradingEnabled: s.paperTradingEnabled ?? true, paperInitialCapital: s.paperInitialCapital ?? 10_000_000, paperFeeRatePct: s.paperFeeRatePct ?? 0.05, paperSlippagePct: s.paperSlippagePct ?? 0, paperMinOrderKrw: s.paperMinOrderKrw ?? 5000, paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false, paperAutoTradeBudgetPct: s.paperAutoTradeBudgetPct ?? 95, paperOrderFillMode: (s.paperOrderFillMode ?? 'LIMIT_ONLY') as 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL', paperOrderAutoCancelPct: s.paperOrderAutoCancelPct ?? 1.0, virtualTargetMaxCount: clampVirtualTargetMax(s.virtualTargetMaxCount ?? DEFAULT_VIRTUAL_TARGET_MAX), tradingMode: s.tradingMode ?? 'PAPER', liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false, hasUpbitKeys: s.hasUpbitKeys ?? false, upbitAccessKeyMasked: s.upbitAccessKeyMasked ?? null, chartRealtimeSource: s.chartRealtimeSource ?? 'BACKEND_STOMP', liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95, fcmPushEnabled: s.fcmPushEnabled ?? false, displayTimezone: normalizeTimezone(s.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE), chartTimeFormat: normalizeChartTimeFormat(s.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT), tradeAlertTimeFormat: normalizeChartTimeFormat(s.tradeAlertTimeFormat ?? DEFAULT_TRADE_ALERT_TIME_FORMAT), trendSearchSettings: resolveTrendSearchAppSettings( s.trendSearchSettings as Partial | null | undefined, ), }; } // ───────────────────────────────────────────────────────────────────────────── /** sessionKey 변경 시(로그인/로그아웃) 설정을 DB에서 다시 로드 */ export function useAppSettings(sessionKey = 0) { const [settings, setSettings] = useState(_cache ?? {}); const [isLoaded, setIsLoaded] = useState(_cache !== null); const mountedRef = useRef(true); const prevSessionKeyRef = useRef(sessionKey); useEffect(() => { mountedRef.current = true; const sessionChanged = prevSessionKeyRef.current !== sessionKey; prevSessionKeyRef.current = sessionKey; if (sessionChanged) { if (_cache !== null) { setSettings(_cache); setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE)); setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT)); setTradeAlertTimeFormat(normalizeChartTimeFormat(_cache.tradeAlertTimeFormat ?? DEFAULT_TRADE_ALERT_TIME_FORMAT)); setIsLoaded(true); } else { setIsLoaded(false); invalidateAppSettingsCache(); } } else if (_cache !== null) { setSettings(_cache); setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE)); setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT)); setTradeAlertTimeFormat(normalizeChartTimeFormat(_cache.tradeAlertTimeFormat ?? DEFAULT_TRADE_ALERT_TIME_FORMAT)); setIsLoaded(true); } else { setIsLoaded(false); } ensureLoaded().then(data => { const safe = data ?? {}; if (mountedRef.current) { setSettings(safe); setDisplayTimezone(normalizeTimezone(safe.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE)); setChartTimeFormat(normalizeChartTimeFormat(safe.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT)); setTradeAlertTimeFormat(normalizeChartTimeFormat(safe.tradeAlertTimeFormat ?? DEFAULT_TRADE_ALERT_TIME_FORMAT)); setIsLoaded(true); } }).catch(err => { console.error('[useAppSettings] load failed', err); if (mountedRef.current) setIsLoaded(true); }); const unsub = subscribeAppSettings(() => { if (mountedRef.current) setSettings({ ...(getAppSettingsCache()) }); }); return () => { mountedRef.current = false; unsub(); }; }, [sessionKey]); /** * 특정 필드를 DB 에 저장하고 로컬 캐시를 업데이트. * 낙관적 업데이트 — DB 저장 실패해도 UI 는 즉시 반영. */ const save = useCallback((patch: AppSettingsDto) => { const { defaultTheme: _omitTheme, ...patchWithoutTheme } = patch; const apiPatch = 'defaultTheme' in patch ? patchWithoutTheme : patch; const merged = { ...(_cache ?? {}), ...apiPatch }; _cache = merged; notifyAppSettingsListeners(); setSettings(merged); if (patch.displayTimezone != null) { setDisplayTimezone(normalizeTimezone(patch.displayTimezone)); } if (patch.chartTimeFormat != null) { setChartTimeFormat(normalizeChartTimeFormat(patch.chartTimeFormat)); } if (patch.tradeAlertTimeFormat != null) { setTradeAlertTimeFormat(normalizeChartTimeFormat(patch.tradeAlertTimeFormat)); } saveAppSettings(apiPatch).then(updated => { if (updated) { _cache = { ...(_cache ?? {}), ...updated }; notifyAppSettingsListeners(); if (mountedRef.current) { setSettings(prev => ({ ...prev, ...updated })); } } }).catch(err => console.error('[useAppSettings] save failed', err) ); }, []); /** 로드된 설정의 해석된 기본값 */ const defaults = resolveAppDefaults(settings); return { settings, defaults, isLoaded, save }; } /** 앱 설정 캐시 기준 투자대상 최대 개수 (훅 외부·추가 API 등) */ export function getVirtualTargetMaxCount(): number { const n = _cache?.virtualTargetMaxCount; return clampVirtualTargetMax(n ?? DEFAULT_VIRTUAL_TARGET_MAX); }