diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 31bb588..f52ebe4 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; +import { AlertsPanel } from "../../components/AlertsPanel"; import { CompositeScoreCard } from "../../components/CompositeScoreCard"; import { DisclosuresPanel } from "../../components/DisclosuresPanel"; import { MetricsPanel } from "../../components/MetricsPanel"; @@ -145,6 +146,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
+
diff --git a/web/app/layout.tsx b/web/app/layout.tsx index adcc45e..cc39e89 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,5 +1,6 @@ import "./globals.css"; import type { Metadata } from "next"; +import { AlertsToaster } from "../components/AlertsToaster"; import { TopNav } from "../components/TopNav"; export const metadata: Metadata = { @@ -13,6 +14,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + ); diff --git a/web/components/AlertsPanel.tsx b/web/components/AlertsPanel.tsx new file mode 100644 index 0000000..7b5977a --- /dev/null +++ b/web/components/AlertsPanel.tsx @@ -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([]); + const [op, setOp] = useState("gte"); + const [target, setTarget] = useState(""); + + 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 ( +
+
+
가격 알람
+
+ {current != null ? `현재가 ${current.toLocaleString()}원` : ""} +
+
+
+
+ + +
+ 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" + /> + +
+ + {items.length === 0 ? ( +
+ 설정된 알람이 없습니다. 임계값을 추가하세요. +
+ ) : ( +
    + {items.map((a) => ( +
  • +
    + + {a.op === "gte" ? "≥" : "≤"} + + + {a.target.toLocaleString()} + + {a.triggered && ( + + 발사됨 + + )} +
    +
    + {a.triggered && ( + + )} + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/components/AlertsToaster.tsx b/web/components/AlertsToaster.tsx new file mode 100644 index 0000000..e89bc97 --- /dev/null +++ b/web/components/AlertsToaster.tsx @@ -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([]); + 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()} +
+ + ))} +
+ ); +} diff --git a/web/lib/alerts.ts b/web/lib/alerts.ts new file mode 100644 index 0000000..2f66967 --- /dev/null +++ b/web/lib/alerts.ts @@ -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 & { 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); + }; + }, +};