Files
goldenChart/frontend/src/contexts/TradeNotificationContext.tsx
T
2026-06-04 16:21:31 +09:00

544 lines
19 KiB
TypeScript

/**
* 실시간 매매 시그널 알림 Context
*
* - STOMP 수신 시 토스트 스택(미확인)에 추가
* - 알림 목록·읽음 상태는 localStorage + 서버 trade-signals 이력 병합
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { TradeSignalInfo } from '../components/TradeAlertModal';
import {
deleteAllTradeSignals,
deleteTradeSignal,
deleteTradeSignalsBatch,
loadTradeSignals,
type TradeSignalDto,
} from '../utils/backendApi';
import {
normalizeTradeAlertSoundId,
playTradeAlertSound,
type TradeAlertSoundId,
} from '../utils/tradeAlertSound';
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
import { useAppSettings } from '../hooks/useAppSettings';
const MAX_HISTORY = 300;
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
const MAX_TOAST_QUEUE = 50;
export interface TradeNotificationItem extends TradeSignalInfo {
/** 클라이언트 고유 키 (market:time:signal) */
id: string;
dbId?: number;
isRead: boolean;
receivedAt: number;
}
interface TradeNotificationContextValue {
/** 우측 스택에 표시 중인 미확인 알림 */
toastNotifications: TradeNotificationItem[];
/** 전체 이력 (목록 화면) */
allNotifications: TradeNotificationItem[];
unreadCount: number;
addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void;
dismissToast: (id: string) => void;
/** 지정 id 알림만 팝업 대기열에서 닫기 (읽음 처리) */
dismissToasts: (ids: string[]) => void;
/** 팝업 대기열(toastNotifications) 전체 닫기 — 모든 페이지 포함 */
dismissAllToasts: () => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
/** 알림 목록에서 단건 삭제 */
deleteNotification: (id: string) => Promise<void>;
/** 지정 id 알림 일괄 삭제 (목록 선택 삭제) */
deleteNotifications: (ids: string[]) => Promise<void>;
/** 읽지 않은(미확인) 알림만 삭제 */
deleteUnreadNotifications: () => Promise<void>;
/** 알림 목록 전체 삭제 */
deleteAllNotifications: () => Promise<void>;
refreshHistory: (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => Promise<void>;
detailSignal: TradeSignalInfo | null;
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
detailCentered: boolean;
openDetail: (item: TradeNotificationItem, options?: { centered?: boolean }) => void;
closeDetail: () => void;
}
const TradeNotificationContext = createContext<TradeNotificationContextValue | null>(null);
function makeId(signal: Pick<TradeSignalInfo, 'market' | 'candleTime' | 'signalType'>): string {
return `${signal.market}:${signal.candleTime}:${signal.signalType}`;
}
function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotificationItem {
const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`;
return {
id,
dbId: dto.id,
market: dto.market,
signalType: dto.signalType,
price: dto.price,
candleTime: dto.candleTime,
strategyName: dto.strategyName,
strategyId: dto.strategyId,
executionType: dto.executionType,
candleType: dto.candleType,
isRead: readIds.has(id),
receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000,
};
}
function loadReadIds(): Set<string> {
const ids = getUiPreferences().tradeNotifications?.readIds;
return new Set(ids ?? []);
}
function saveReadIds(ids: Set<string>, immediate = false) {
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } }, immediate);
}
function loadHiddenIds(): Set<string> {
const ids = getUiPreferences().tradeNotifications?.hiddenIds;
return new Set(ids ?? []);
}
function saveHiddenIds(ids: Set<string>) {
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
}
export function useTradeNotification(): TradeNotificationContextValue {
const ctx = useContext(TradeNotificationContext);
if (!ctx) {
throw new Error('useTradeNotification must be used within TradeNotificationProvider');
}
return ctx;
}
interface ProviderProps {
children: React.ReactNode;
/** 매매 알림 팝업 ON (설정) */
popupEnabled: boolean;
/** 매매 알림 사운드 ON (설정) */
soundEnabled?: boolean;
/** 알림음 ID */
soundId?: string;
/** 로그인·게스트 전환 시 app-settings 재로드 키 */
settingsSessionKey?: number;
}
export const TradeNotificationProvider: React.FC<ProviderProps> = ({
children,
popupEnabled,
soundEnabled = true,
soundId = 'bell',
settingsSessionKey = 0,
}) => {
const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
const alertSoundId = normalizeTradeAlertSoundId(soundId) as TradeAlertSoundId;
const [readIds, setReadIds] = useState<Set<string>>(() => loadReadIds());
const readIdsRef = useRef(readIds);
readIdsRef.current = readIds;
const [toastNotifications, setToastNotifications] = useState<TradeNotificationItem[]>([]);
const toastNotificationsRef = useRef(toastNotifications);
toastNotificationsRef.current = toastNotifications;
const [allNotifications, setAllNotifications] = useState<TradeNotificationItem[]>([]);
const allNotificationsRef = useRef(allNotifications);
allNotificationsRef.current = allNotifications;
/**
* addNotification 에서 수신된 모든 알림 ID 를 동기적으로 누적.
* dismissAllToasts 가 호출될 때 allNotificationsRef 가 stale 한 경우에도
* 같은 틱에 수신된 신호 ID 까지 readIds 에 포함시킬 수 있다.
*/
const allSeenIdsRef = useRef<Set<string>>(new Set());
const [detailSignal, setDetailSignal] = useState<TradeSignalInfo | null>(null);
const [detailCentered, setDetailCentered] = useState(false);
const unreadCount = useMemo(
() => allNotifications.filter(n => !n.isRead).length,
[allNotifications],
);
const refreshHistory = useCallback(async (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => {
try {
const dtos = await loadTradeSignals();
/**
* readIds 의 최신 상태를 가져올 때:
* - readIdsRef.current : dismissAllToasts / markAllAsRead 직후 즉시 반영되는 인메모리 값
* - loadReadIds() : DB 캐시 기반 값 (약간의 지연 가능)
* 두 Set 의 합집합(union)을 사용하면 어느 쪽 타이밍 경쟁에서도 읽음 상태가 보존된다.
*/
const cacheRead = loadReadIds();
const memRead = readIdsRef.current;
const read = new Set([...cacheRead, ...memRead]);
const hidden = opts?.rawServerList ? new Set<string>() : loadHiddenIds();
const items = dtos
.map(d => dtoToItem(d, read))
.filter(item => opts?.rawServerList || !hidden.has(item.id));
let newlyArrived: TradeNotificationItem[] = [];
setAllNotifications(prev => {
/**
* rawServerList=true 는 앱 초기 로드(새로고침·로그인 직후)이므로
* 이미 존재하는 서버 이력을 실시간 신규 알림으로 취급하지 않는다.
* 실제 새 알림 판단은 STOMP addNotification 과 일반 refreshHistory 에서만 한다.
*/
if (!opts?.rawServerList) {
newlyArrived = items.filter(
item =>
!item.isRead &&
!readIdsRef.current.has(item.id) && // readIds 에 없는(진짜 새) 알림만
!prev.some(p => p.id === item.id), // 이미 목록에 없는 알림만
);
}
if (opts?.serverOnly || opts?.rawServerList) {
return items
.sort((a, b) => b.receivedAt - a.receivedAt)
.slice(0, MAX_HISTORY);
}
const map = new Map<string, TradeNotificationItem>();
for (const n of items) {
const wasRead = read.has(n.id) || prev.find(p => p.id === n.id)?.isRead;
map.set(n.id, wasRead ? { ...n, isRead: true } : n);
}
for (const n of prev) {
if (hidden.has(n.id)) continue;
// 서버 이력에 없는 STOMP-only 항목은 세션 전환 후 제거 (serverOnly가 아닐 때만 병합)
if (!map.has(n.id)) map.set(n.id, n);
else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true });
}
return [...map.values()]
.filter(n => !hidden.has(n.id))
.sort((a, b) => b.receivedAt - a.receivedAt)
.slice(0, MAX_HISTORY);
});
if (popupEnabled && newlyArrived.length > 0) {
setToastNotifications(tprev => {
const map = new Map(tprev.map(t => [t.id, t]));
for (const n of newlyArrived) map.set(n.id, n);
return [...map.values()].slice(0, MAX_TOAST_QUEUE);
});
if (soundEnabled) {
playTradeAlertSound(alertSoundId);
}
}
} catch (e) {
console.warn('[TradeNotification] 이력 로드 실패:', e);
}
}, [popupEnabled, soundEnabled, alertSoundId]);
useEffect(() => {
if (!settingsLoaded) return;
/**
* 앱 설정이 DB 에서 로드 완료된 시점에 readIds 를 즉시 동기화한다.
* STOMP 가 앱 시작 직후 구독 시 readIds 가 아직 빈 상태여서 이미 닫은
* 알림이 다시 팝업으로 뜨는 타이밍 버그를 방지한다.
* 세션 전환 시 allSeenIdsRef 도 초기화해 이전 세션 알림 ID 가 잔류하지 않게 한다.
*/
allSeenIdsRef.current = new Set();
const dbRead = loadReadIds();
const merged = new Set([...readIdsRef.current, ...dbRead]);
if (merged.size !== readIdsRef.current.size) {
readIdsRef.current = merged;
setReadIds(merged);
}
setToastNotifications([]);
void refreshHistory({ serverOnly: true, rawServerList: true });
}, [refreshHistory, settingsLoaded, settingsSessionKey]);
const markAsRead = useCallback((id: string) => {
setReadIds(prev => {
const next = new Set(prev);
next.add(id);
saveReadIds(next);
readIdsRef.current = next;
return next;
});
setToastNotifications(prev => prev.filter(n => n.id !== id));
setAllNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, isRead: true } : n)),
);
}, []);
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
const id = makeId(signal);
// 수신한 모든 ID 를 동기적으로 누적 (dismissAllToasts 가 참조)
allSeenIdsRef.current.add(id);
/**
* 읽음 여부 3중 확인:
* 1. readIdsRef.current — dismissAllToasts 가 즉시 업데이트하는 인메모리 ref
* 2. loadReadIds() — DB 캐시 기반 값
* 3. allNotificationsRef — dismissAllToasts 의 functional updater 가
* isRead:true 를 예약했지만 readIdsRef 에 아직 반영되지 않은 경우 커버
*/
const alreadyRead =
readIdsRef.current.has(id) ||
loadReadIds().has(id) ||
(allNotificationsRef.current.find(n => n.id === id)?.isRead === true);
// readIds 에 누락된 읽음 항목 보정 (다음 STOMP/폴링에서 즉시 필터링)
if (alreadyRead && !readIdsRef.current.has(id)) {
const next = new Set(readIdsRef.current);
next.add(id);
readIdsRef.current = next;
saveReadIds(next);
}
setAllNotifications(prev => {
const existing = prev.find(n => n.id === id);
if (existing) {
// 읽음 처리된 항목은 isRead 를 false 로 되돌리지 않는다
if (existing.isRead || alreadyRead) return prev;
return prev.map(n =>
n.id === id ? { ...n, isRead: false, receivedAt: Date.now() } : n,
);
}
const item: TradeNotificationItem = {
...signal,
id,
dbId: signal.dbId,
candleType: signal.candleType ?? '1m',
isRead: alreadyRead,
receivedAt: Date.now(),
};
return [item, ...prev].slice(0, MAX_HISTORY);
});
// 이미 읽음 처리된 알림은 팝업·알림음 모두 생략
if (alreadyRead) return;
if (soundEnabled) {
playTradeAlertSound(alertSoundId);
}
if (!popupEnabled) return;
const toastItem: TradeNotificationItem = {
...signal,
id,
dbId: signal.dbId,
candleType: signal.candleType ?? '1m',
isRead: false,
receivedAt: Date.now(),
};
setToastNotifications(prev => {
const rest = prev.filter(n => n.id !== id);
return [toastItem, ...rest].slice(0, MAX_TOAST_QUEUE);
});
}, [popupEnabled, soundEnabled, alertSoundId]);
const dismissToast = useCallback((id: string) => {
markAsRead(id);
}, [markAsRead]);
const dismissToasts = useCallback((ids: string[]) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
const toastSnapshot = toastNotificationsRef.current;
setReadIds(r => {
const next = new Set(r);
ids.forEach(i => next.add(i));
saveReadIds(next);
readIdsRef.current = next;
return next;
});
setToastNotifications(prev => prev.filter(n => !idSet.has(n.id)));
setAllNotifications(all => {
const byId = new Map(all.map(n => [n.id, n]));
for (const id of ids) {
const existing = byId.get(id);
if (existing) {
byId.set(id, { ...existing, isRead: true });
} else {
const fromToast = toastSnapshot.find(n => n.id === id);
if (fromToast) byId.set(id, { ...fromToast, isRead: true });
}
}
return [...byId.values()]
.sort((a, b) => b.receivedAt - a.receivedAt)
.slice(0, MAX_HISTORY);
});
}, []);
const dismissAllToasts = useCallback(() => {
/**
* 전체 닫기 — 모든 알림을 읽음 처리하고 즉시 DB 저장.
*
* allSeenIdsRef: addNotification 에서 동기적으로 누적된 ID 집합.
* - allNotificationsRef.current 가 stale 하더라도(배치 미반영)
* 동일 틱에 수신된 STOMP 신호 ID 도 포함 → readIds 에서 누락 없음.
* side-effect(readIdsRef, saveReadIds)를 functional updater 밖에서 실행하여
* React concurrent 렌더링에서의 중복 실행 문제를 방지.
*/
const nextRead = new Set([
...readIdsRef.current,
...toastNotificationsRef.current.map(n => n.id),
...allNotificationsRef.current.map(n => n.id),
...allSeenIdsRef.current, // 동일 틱에 수신됐지만 ref 미반영 ID
]);
// 1) 인메모리 ref 즉시 갱신 (다음 addNotification 호출에서 즉시 참조)
readIdsRef.current = nextRead;
// 2) React state 갱신
setReadIds(nextRead);
setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
setToastNotifications([]);
// 3) 인메모리 캐시 + 즉시 DB 저장 (immediate=true)
saveReadIds(nextRead, true);
}, []);
const markAllAsRead = useCallback(() => {
const nextRead = new Set([
...readIdsRef.current,
...allNotificationsRef.current.map(n => n.id),
...allSeenIdsRef.current,
]);
readIdsRef.current = nextRead;
setReadIds(nextRead);
setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
setToastNotifications([]);
saveReadIds(nextRead, true);
}, []);
const purgeNotifications = useCallback((ids: string[]) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
const hidden = loadHiddenIds();
ids.forEach(id => hidden.add(id));
saveHiddenIds(hidden);
setReadIds(prev => {
const next = new Set(prev);
ids.forEach(id => next.delete(id));
saveReadIds(next);
readIdsRef.current = next;
return next;
});
setToastNotifications(prev => prev.filter(n => !idSet.has(n.id)));
setAllNotifications(prev => prev.filter(n => !idSet.has(n.id)));
if (detailSignal) {
const detailId = makeId({
market: detailSignal.market,
candleTime: detailSignal.candleTime,
signalType: detailSignal.signalType,
});
if (idSet.has(detailId)) {
setDetailSignal(null);
setDetailCentered(false);
}
}
}, [detailSignal]);
const findNotificationById = useCallback((id: string): TradeNotificationItem | undefined => {
return allNotifications.find(n => n.id === id)
?? toastNotificationsRef.current.find(n => n.id === id);
}, [allNotifications]);
const deleteNotifications = useCallback(async (ids: string[]) => {
const unique = [...new Set(ids)].filter(Boolean);
if (unique.length === 0) return;
const dbIds: number[] = [];
for (const id of unique) {
const item = findNotificationById(id);
if (item?.dbId != null) dbIds.push(item.dbId);
}
purgeNotifications(unique);
if (dbIds.length > 0) {
try {
await deleteTradeSignalsBatch(dbIds);
} catch (e) {
console.warn('[TradeNotification] 일괄 삭제 실패:', e);
}
}
}, [findNotificationById, purgeNotifications]);
const deleteNotification = useCallback(async (id: string) => {
await deleteNotifications([id]);
}, [deleteNotifications]);
const deleteUnreadNotifications = useCallback(async () => {
const unread = allNotifications.filter(n => !n.isRead);
if (unread.length === 0) return;
await deleteNotifications(unread.map(n => n.id));
}, [allNotifications, deleteNotifications]);
const deleteAllNotifications = useCallback(async () => {
const ids = [
...new Set([
...allNotifications.map(n => n.id),
...toastNotificationsRef.current.map(n => n.id),
]),
];
purgeNotifications(ids);
setToastNotifications([]);
saveReadIds(new Set());
saveHiddenIds(new Set());
try {
await deleteAllTradeSignals();
} catch (e) {
console.warn('[TradeNotification] 전체 삭제 실패:', e);
}
}, [allNotifications, purgeNotifications]);
const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => {
setDetailCentered(options?.centered ?? false);
setDetailSignal({
market: item.market,
signalType: item.signalType,
price: item.price,
candleTime: item.candleTime,
strategyName: item.strategyName,
strategyId: item.strategyId,
executionType: item.executionType,
candleType: item.candleType,
});
markAsRead(item.id);
}, [markAsRead]);
const closeDetail = useCallback(() => {
setDetailSignal(null);
setDetailCentered(false);
}, []);
const value: TradeNotificationContextValue = {
toastNotifications,
allNotifications,
unreadCount,
addNotification,
dismissToast,
dismissToasts,
dismissAllToasts,
markAsRead,
markAllAsRead,
deleteNotification,
deleteNotifications,
deleteUnreadNotifications,
deleteAllNotifications,
refreshHistory,
detailSignal,
detailCentered,
openDetail,
closeDetail,
};
return (
<TradeNotificationContext.Provider value={value}>
{children}
</TradeNotificationContext.Provider>
);
};