대시보드 실시간 기능 추가
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import {
|
||||
fetchDashboardActivity,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import type {
|
||||
DashboardActivityResponse,
|
||||
DashboardBalanceResponse,
|
||||
DashboardHoldingItem,
|
||||
DashboardIndicesResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
@@ -18,7 +17,6 @@ interface UseDashboardDataResult {
|
||||
activity: DashboardActivityResponse | null;
|
||||
balance: DashboardBalanceResponse | null;
|
||||
indices: DashboardIndicesResponse["items"];
|
||||
selectedHolding: DashboardHoldingItem | null;
|
||||
selectedSymbol: string | null;
|
||||
setSelectedSymbol: (symbol: string) => void;
|
||||
isLoading: boolean;
|
||||
@@ -179,11 +177,6 @@ export function useDashboardData(
|
||||
return () => window.clearInterval(interval);
|
||||
}, [credentials, refreshInternal]);
|
||||
|
||||
const selectedHolding = useMemo(() => {
|
||||
if (!selectedSymbol || !balance) return null;
|
||||
return balance.holdings.find((item) => item.symbol === selectedSymbol) ?? null;
|
||||
}, [balance, selectedSymbol]);
|
||||
|
||||
const setSelectedSymbol = useCallback((symbol: string) => {
|
||||
setSelectedSymbolState(symbol);
|
||||
}, []);
|
||||
@@ -192,7 +185,6 @@ export function useDashboardData(
|
||||
activity,
|
||||
balance,
|
||||
indices,
|
||||
selectedHolding,
|
||||
selectedSymbol,
|
||||
setSelectedSymbol,
|
||||
isLoading,
|
||||
|
||||
76
features/dashboard/hooks/use-holdings-realtime.ts
Normal file
76
features/dashboard/hooks/use-holdings-realtime.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
type KisRealtimeStockTick,
|
||||
parseKisRealtimeStockTick,
|
||||
} from "@/features/dashboard/utils/kis-stock-realtime.utils";
|
||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||
|
||||
const STOCK_REALTIME_TR_ID = "H0STCNT0";
|
||||
|
||||
/**
|
||||
* @description 보유 종목 목록에 대한 실시간 체결 데이터를 구독합니다.
|
||||
* @param holdings 보유 종목 목록
|
||||
* @returns 종목별 실시간 체결 데이터/연결 상태
|
||||
* @remarks UI 흐름: DashboardContainer -> useHoldingsRealtime -> HoldingsList/summary 실시간 반영
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 보유종목 실시간 병합
|
||||
*/
|
||||
export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) {
|
||||
const [realtimeData, setRealtimeData] = useState<
|
||||
Record<string, KisRealtimeStockTick>
|
||||
>({});
|
||||
const { subscribe, connect, isConnected } = useKisWebSocketStore();
|
||||
|
||||
const uniqueSymbols = useMemo(
|
||||
() => Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(),
|
||||
[holdings],
|
||||
);
|
||||
const symbolKey = useMemo(() => uniqueSymbols.join(","), [uniqueSymbols]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uniqueSymbols.length === 0) {
|
||||
const resetTimerId = window.setTimeout(() => {
|
||||
setRealtimeData({});
|
||||
}, 0);
|
||||
return () => window.clearTimeout(resetTimerId);
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
connect();
|
||||
}
|
||||
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
uniqueSymbols.forEach((symbol) => {
|
||||
const unsub = subscribe(STOCK_REALTIME_TR_ID, symbol, (data) => {
|
||||
const tick = parseKisRealtimeStockTick(data);
|
||||
if (tick) {
|
||||
setRealtimeData((prev) => {
|
||||
const prevTick = prev[tick.symbol];
|
||||
if (
|
||||
prevTick?.currentPrice === tick.currentPrice &&
|
||||
prevTick?.change === tick.change &&
|
||||
prevTick?.changeRate === tick.changeRate
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[tick.symbol]: tick,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
unsubs.push(unsub);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
};
|
||||
}, [symbolKey, uniqueSymbols, connect, subscribe, isConnected]);
|
||||
|
||||
return { realtimeData, isConnected };
|
||||
}
|
||||
77
features/dashboard/hooks/use-market-realtime.ts
Normal file
77
features/dashboard/hooks/use-market-realtime.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
parseKisRealtimeIndexTick,
|
||||
type KisRealtimeIndexTick,
|
||||
} from "@/features/dashboard/utils/kis-index-realtime.utils";
|
||||
import { useKisWebSocket } from "@/features/kis-realtime/hooks/useKisWebSocket";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
|
||||
const INDEX_TR_ID = "H0UPCNT0";
|
||||
const KOSPI_SYMBOL = "0001";
|
||||
const KOSDAQ_SYMBOL = "1001";
|
||||
|
||||
interface UseMarketRealtimeResult {
|
||||
realtimeIndices: Record<string, KisRealtimeIndexTick>;
|
||||
isConnected: boolean;
|
||||
hasReceivedTick: boolean;
|
||||
isPending: boolean;
|
||||
lastTickAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 코스피/코스닥 실시간 지수 웹소켓 구독 상태를 관리합니다.
|
||||
* @param credentials KIS 인증 정보(하위 호환 파라미터)
|
||||
* @param isVerified KIS 연결 인증 여부
|
||||
* @returns 실시간 지수 맵/연결 상태/수신 대기 상태
|
||||
* @remarks UI 흐름: DashboardContainer -> useMarketRealtime -> MarketSummary/StatusHeader 렌더링 반영
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 지수 데이터 통합 및 상태 전달
|
||||
*/
|
||||
export function useMarketRealtime(
|
||||
_credentials: KisRuntimeCredentials | null, // 하위 호환성을 위해 남겨둠 (실제로는 스토어 사용)
|
||||
isVerified: boolean, // 하위 호환성을 위해 남겨둠
|
||||
): UseMarketRealtimeResult {
|
||||
const [realtimeIndices, setRealtimeIndices] = useState<
|
||||
Record<string, KisRealtimeIndexTick>
|
||||
>({});
|
||||
const [lastTickAt, setLastTickAt] = useState<string | null>(null);
|
||||
|
||||
const handleMessage = useCallback((data: string) => {
|
||||
const tick = parseKisRealtimeIndexTick(data);
|
||||
if (tick) {
|
||||
setLastTickAt(new Date().toISOString());
|
||||
setRealtimeIndices((prev) => ({
|
||||
...prev,
|
||||
[tick.symbol]: tick,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// KOSPI 구독
|
||||
const { isConnected: isKospiConnected } = useKisWebSocket({
|
||||
symbol: KOSPI_SYMBOL,
|
||||
trId: INDEX_TR_ID,
|
||||
onMessage: handleMessage,
|
||||
enabled: isVerified,
|
||||
});
|
||||
|
||||
// KOSDAQ 구독
|
||||
const { isConnected: isKosdaqConnected } = useKisWebSocket({
|
||||
symbol: KOSDAQ_SYMBOL,
|
||||
trId: INDEX_TR_ID,
|
||||
onMessage: handleMessage,
|
||||
enabled: isVerified,
|
||||
});
|
||||
|
||||
const hasReceivedTick = Object.keys(realtimeIndices).length > 0;
|
||||
const isPending = isVerified && (isKospiConnected || isKosdaqConnected) && !hasReceivedTick;
|
||||
|
||||
return {
|
||||
realtimeIndices,
|
||||
isConnected: isKospiConnected || isKosdaqConnected,
|
||||
hasReceivedTick,
|
||||
isPending,
|
||||
lastTickAt,
|
||||
};
|
||||
}
|
||||
73
features/dashboard/hooks/use-price-flash.ts
Normal file
73
features/dashboard/hooks/use-price-flash.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const FLASH_DURATION_MS = 2_000;
|
||||
|
||||
/**
|
||||
* @description 가격 변동 시 일시 플래시(+/-) 값을 생성합니다.
|
||||
* @param currentPrice 현재가
|
||||
* @param key 종목 식별 키(종목 변경 시 상태 초기화)
|
||||
* @returns 플래시 값(up/down, 변화량) 또는 null
|
||||
* @remarks UI 흐름: 시세 변경 -> usePriceFlash -> 플래시 값 노출 -> 2초 후 자동 제거
|
||||
* @see features/dashboard/components/HoldingsList.tsx 보유종목 현재가 플래시
|
||||
* @see features/dashboard/components/StockDetailPreview.tsx 상세 카드 현재가 플래시
|
||||
*/
|
||||
export function usePriceFlash(currentPrice: number, key?: string) {
|
||||
const [flash, setFlash] = useState<{
|
||||
val: number;
|
||||
type: "up" | "down";
|
||||
id: number;
|
||||
} | null>(null);
|
||||
|
||||
const prevKeyRef = useRef<string | undefined>(key);
|
||||
const prevPriceRef = useRef<number>(currentPrice);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const keyChanged = prevKeyRef.current !== key;
|
||||
|
||||
if (keyChanged) {
|
||||
prevKeyRef.current = key;
|
||||
prevPriceRef.current = currentPrice;
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
const resetTimerId = window.setTimeout(() => {
|
||||
setFlash(null);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(resetTimerId);
|
||||
}
|
||||
|
||||
const prevPrice = prevPriceRef.current;
|
||||
const diff = currentPrice - prevPrice;
|
||||
prevPriceRef.current = currentPrice;
|
||||
|
||||
if (prevPrice === 0 || Math.abs(diff) === 0) return;
|
||||
|
||||
// 플래시가 보이는 동안에는 새 플래시를 덮어쓰지 않아 화면 잔상이 지속되지 않게 합니다.
|
||||
if (timerRef.current !== null) return;
|
||||
|
||||
setFlash({
|
||||
val: diff,
|
||||
type: diff > 0 ? "up" : "down",
|
||||
id: Date.now(),
|
||||
});
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setFlash(null);
|
||||
timerRef.current = null;
|
||||
}, FLASH_DURATION_MS);
|
||||
}, [currentPrice, key]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return flash;
|
||||
}
|
||||
Reference in New Issue
Block a user