- lib/alerts.ts: id/code/op(gte|lte)/target/triggered 저장소, watchlist/recent 와 동일 pub/sub - AlertsPanel: 종목 사이드바에 ≥/≤ 토글 + 목표가 입력 + 활성 알람 리스트(재무장/삭제) - AlertsToaster: layout 전역 마운트, 미발사 알람 코드만 폴링, 조건 충족시 markTriggered + 우측 하단 토스트(6s), Notification 권한 허용 시 브라우저 알림 병행
147 lines
4.7 KiB
TypeScript
147 lines
4.7 KiB
TypeScript
"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<Alert[]>([]);
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
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<string, number | null>();
|
|
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 (
|
|
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
|
|
{toasts.map((t) => (
|
|
<Link
|
|
key={t.id}
|
|
href={`/${t.code}`}
|
|
className="pointer-events-auto block w-72 rounded-md border border-amber-500/40 bg-zinc-950/95 p-3 shadow-xl backdrop-blur"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-xs font-medium text-amber-300">🔔 가격 알람</span>
|
|
<span className="text-[10px] text-zinc-500">{t.code}</span>
|
|
</div>
|
|
<div className="mt-1 text-sm font-semibold text-zinc-100">
|
|
{t.name ?? t.code}
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-zinc-400">
|
|
{t.op === "gte" ? "≥" : "≤"} {t.target.toLocaleString()} 도달 ·
|
|
현재 <span className="tabular-nums text-zinc-100">{t.price.toLocaleString()}</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|