feat(alerts): 가격 알람 (localStorage + 60s 폴러 + 토스트)
- lib/alerts.ts: id/code/op(gte|lte)/target/triggered 저장소, watchlist/recent 와 동일 pub/sub - AlertsPanel: 종목 사이드바에 ≥/≤ 토글 + 목표가 입력 + 활성 알람 리스트(재무장/삭제) - AlertsToaster: layout 전역 마운트, 미발사 알람 코드만 폴링, 조건 충족시 markTriggered + 우측 하단 토스트(6s), Notification 권한 허용 시 브라우저 알림 병행
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { AlertsPanel } from "../../components/AlertsPanel";
|
||||||
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
||||||
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
||||||
import { MetricsPanel } from "../../components/MetricsPanel";
|
import { MetricsPanel } from "../../components/MetricsPanel";
|
||||||
@@ -145,6 +146,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SymbolSidebar code={code} />
|
<SymbolSidebar code={code} />
|
||||||
|
<AlertsPanel code={code} name={chart?.name} current={current} />
|
||||||
<RelatedStocks code={code} />
|
<RelatedStocks code={code} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { AlertsToaster } from "../components/AlertsToaster";
|
||||||
import { TopNav } from "../components/TopNav";
|
import { TopNav } from "../components/TopNav";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -13,6 +14,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<body className="min-h-screen">
|
<body className="min-h-screen">
|
||||||
<TopNav />
|
<TopNav />
|
||||||
{children}
|
{children}
|
||||||
|
<AlertsToaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
143
web/components/AlertsPanel.tsx
Normal file
143
web/components/AlertsPanel.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 종목 페이지의 가격 알람 패널.
|
||||||
|
// 현재 종목의 활성 알람 리스트 + 새 알람 추가 폼 (≥ / ≤ + 가격).
|
||||||
|
// 발사 자체는 AlertsToaster 가 글로벌로 처리. 여기는 입력/표시만.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { alerts, type Alert, type AlertOp } from "../lib/alerts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
code: string;
|
||||||
|
name?: string | null;
|
||||||
|
// 현재가 — 폼 placeholder 와 기본값으로 사용.
|
||||||
|
current?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AlertsPanel({ code, name, current }: Props) {
|
||||||
|
const [items, setItems] = useState<Alert[]>([]);
|
||||||
|
const [op, setOp] = useState<AlertOp>("gte");
|
||||||
|
const [target, setTarget] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => setItems(alerts.listForCode(code));
|
||||||
|
refresh();
|
||||||
|
return alerts.subscribe(refresh);
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
const n = Number(target.replace(/[, ]/g, ""));
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return;
|
||||||
|
alerts.add({ code, name: name ?? undefined, op, target: n });
|
||||||
|
setTarget("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
|
<div className="mb-3 flex items-baseline justify-between">
|
||||||
|
<div className="text-sm font-medium text-zinc-200">가격 알람</div>
|
||||||
|
<div className="text-[10px] text-zinc-500">
|
||||||
|
{current != null ? `현재가 ${current.toLocaleString()}원` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-[11px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOp("gte")}
|
||||||
|
className={`px-2 py-1 ${
|
||||||
|
op === "gte"
|
||||||
|
? "bg-zinc-800 text-rose-300"
|
||||||
|
: "bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
≥ 도달 시
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOp("lte")}
|
||||||
|
className={`px-2 py-1 ${
|
||||||
|
op === "lte"
|
||||||
|
? "bg-zinc-800 text-sky-300"
|
||||||
|
: "bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
≤ 하락 시
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={target}
|
||||||
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") onAdd();
|
||||||
|
}}
|
||||||
|
placeholder={current != null ? String(Math.round(current)) : "목표가"}
|
||||||
|
className="w-28 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-zinc-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={!target.trim()}
|
||||||
|
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-[11px] text-amber-300 hover:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="mt-3 text-[11px] text-zinc-500">
|
||||||
|
설정된 알람이 없습니다. 임계값을 추가하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 divide-y divide-zinc-800">
|
||||||
|
{items.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center justify-between gap-2 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-sm px-1 py-0.5 text-[10px] font-semibold ${
|
||||||
|
a.op === "gte"
|
||||||
|
? "bg-rose-900/50 text-rose-300"
|
||||||
|
: "bg-sky-900/50 text-sky-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{a.op === "gte" ? "≥" : "≤"}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums text-zinc-100">
|
||||||
|
{a.target.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{a.triggered && (
|
||||||
|
<span className="ml-1 rounded-sm bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-400">
|
||||||
|
발사됨
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{a.triggered && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => alerts.reset(a.id)}
|
||||||
|
className="rounded-sm border border-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
재무장
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => alerts.remove(a.id)}
|
||||||
|
className="rounded-sm px-1 text-[10px] text-zinc-500 hover:text-rose-300"
|
||||||
|
aria-label="알람 삭제"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
web/components/AlertsToaster.tsx
Normal file
146
web/components/AlertsToaster.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
web/lib/alerts.ts
Normal file
104
web/lib/alerts.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// 가격 알람 — localStorage 기반. 단순 임계값 알람.
|
||||||
|
//
|
||||||
|
// 스키마 (Alert):
|
||||||
|
// id 랜덤 문자열
|
||||||
|
// code 종목코드
|
||||||
|
// name 등록 시점의 종목명 (표시용)
|
||||||
|
// op "gte" = 현재가 ≥ target, "lte" = 현재가 ≤ target
|
||||||
|
// target 임계값 (원)
|
||||||
|
// triggered 한 번 발사되면 true. 사용자가 명시적으로 다시 켜기 전까지 침묵.
|
||||||
|
// createdAt epoch ms
|
||||||
|
//
|
||||||
|
// 발사는 AlertsToaster (글로벌 폴러) 가 처리. 이 모듈은 저장소+pub/sub 만 담당.
|
||||||
|
|
||||||
|
const KEY = "stockchart.alerts";
|
||||||
|
|
||||||
|
export type AlertOp = "gte" | "lte";
|
||||||
|
|
||||||
|
export type Alert = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name?: string;
|
||||||
|
op: AlertOp;
|
||||||
|
target: number;
|
||||||
|
triggered: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function read(): Alert[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(v)) return [];
|
||||||
|
return v.filter(
|
||||||
|
(x): x is Alert =>
|
||||||
|
x &&
|
||||||
|
typeof x.id === "string" &&
|
||||||
|
typeof x.code === "string" &&
|
||||||
|
(x.op === "gte" || x.op === "lte") &&
|
||||||
|
typeof x.target === "number" &&
|
||||||
|
typeof x.triggered === "boolean",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(items: Alert[]) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.setItem(KEY, JSON.stringify(items));
|
||||||
|
window.dispatchEvent(new CustomEvent("alerts:change"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function genId(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const alerts = {
|
||||||
|
list(): Alert[] {
|
||||||
|
return read();
|
||||||
|
},
|
||||||
|
listForCode(code: string): Alert[] {
|
||||||
|
return read().filter((a) => a.code === code);
|
||||||
|
},
|
||||||
|
add(input: Omit<Alert, "id" | "triggered" | "createdAt"> & { triggered?: boolean }) {
|
||||||
|
const cur = read();
|
||||||
|
cur.unshift({
|
||||||
|
id: genId(),
|
||||||
|
triggered: input.triggered ?? false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
code: input.code,
|
||||||
|
name: input.name,
|
||||||
|
op: input.op,
|
||||||
|
target: input.target,
|
||||||
|
});
|
||||||
|
write(cur);
|
||||||
|
},
|
||||||
|
remove(id: string) {
|
||||||
|
write(read().filter((a) => a.id !== id));
|
||||||
|
},
|
||||||
|
markTriggered(id: string) {
|
||||||
|
write(read().map((a) => (a.id === id ? { ...a, triggered: true } : a)));
|
||||||
|
},
|
||||||
|
reset(id: string) {
|
||||||
|
write(read().map((a) => (a.id === id ? { ...a, triggered: false } : a)));
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
write([]);
|
||||||
|
},
|
||||||
|
subscribe(cb: () => void): () => void {
|
||||||
|
if (typeof window === "undefined") return () => {};
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === KEY) cb();
|
||||||
|
};
|
||||||
|
const onCustom = () => cb();
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
window.addEventListener("alerts:change", onCustom as EventListener);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
window.removeEventListener("alerts:change", onCustom as EventListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user