"use client"; // 글로벌 가격 알람 폴러 + 인페이지 토스트. // // 동작: // 1) alerts 구독 + 60s 인터벌마다 미발사(triggered=false) 알람 코드 set 수집 // 2) 각 코드별 /api/chart?days=2&interval=1d → 마지막 종가만 사용 // 3) 조건 (gte: close >= target, lte: close <= target) 만족 시 markTriggered + 토스트 // // 알람이 0건이면 폴링 자체를 하지 않음 (네트워크 정숙). // 토스트는 우측 하단에 쌓이며 6초 후 자동 dismiss. // // 브라우저 Notification 은 사용자가 허용한 경우에만 발송. import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { api } from "../lib/api"; import { alerts, type Alert } from "../lib/alerts"; type Toast = { id: string; code: string; name?: string; op: Alert["op"]; target: number; price: number; }; const POLL_MS = 60_000; const TOAST_TTL_MS = 6_000; export function AlertsToaster() { const [active, setActive] = useState([]); const [toasts, setToasts] = useState([]); const tickingRef = useRef(false); useEffect(() => { const refresh = () => setActive(alerts.list().filter((a) => !a.triggered)); refresh(); return alerts.subscribe(refresh); }, []); // 폴링. useEffect(() => { if (active.length === 0) return; let alive = true; const codes = Array.from(new Set(active.map((a) => a.code))); const tick = async () => { if (tickingRef.current) return; tickingRef.current = true; try { // 종목별 마지막 종가 (직전 2 거래일만 받음 — 가볍게). const prices = new Map(); await Promise.allSettled( codes.map(async (code) => { const c = await api.getChart(code, 2, "1d"); const valid = c.ohlcv.filter((p) => p.close != null); const last = valid[valid.length - 1] ?? null; prices.set(code, last?.close ?? null); }), ); if (!alive) return; // 알람 평가 — 최신 스냅샷(read)으로 재조회 (구독 사이 추가/삭제 반영). const fresh = alerts.list().filter((a) => !a.triggered); const newToasts: Toast[] = []; for (const a of fresh) { const p = prices.get(a.code); if (p == null) continue; const hit = (a.op === "gte" && p >= a.target) || (a.op === "lte" && p <= a.target); if (!hit) continue; alerts.markTriggered(a.id); newToasts.push({ id: a.id, code: a.code, name: a.name, op: a.op, target: a.target, price: p, }); // 브라우저 알림 (권한 허용 시). if ( typeof window !== "undefined" && "Notification" in window && Notification.permission === "granted" ) { try { new Notification(`${a.name ?? a.code} 알람`, { body: `${a.op === "gte" ? "≥" : "≤"} ${a.target.toLocaleString()} · 현재 ${p.toLocaleString()}`, }); } catch { // 무시 } } } if (newToasts.length) { setToasts((cur) => [...cur, ...newToasts]); for (const t of newToasts) { window.setTimeout(() => { setToasts((cur) => cur.filter((x) => x.id !== t.id)); }, TOAST_TTL_MS); } } } finally { tickingRef.current = false; } }; tick(); const h = window.setInterval(tick, POLL_MS); return () => { alive = false; window.clearInterval(h); }; }, [active]); if (toasts.length === 0) return null; return (
{toasts.map((t) => (
🔔 가격 알람 {t.code}
{t.name ?? t.code}
{t.op === "gte" ? "≥" : "≤"} {t.target.toLocaleString()} 도달 · 현재 {t.price.toLocaleString()}
))}
); }