From 139403c133106cf77fbf67579e72f1e683437046 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 18:25:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(alerts):=20/alerts=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20+=20TopNav?= =?UTF-8?q?=20=F0=9F=94=94=20=EB=A7=81=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종목별 그룹핑 (Map by code), 활성·발사됨 분리 카운트 - 행 단위 재무장/삭제 + 전체 삭제 (confirm) - Notification 권한이 default 일 때만 권한 요청 배너 노출 - TopNav 우측 ★ 옆에 🔔 알람 페이지 링크 추가 --- web/app/alerts/page.tsx | 211 ++++++++++++++++++++++++++++++++++++++ web/components/TopNav.tsx | 7 ++ 2 files changed, 218 insertions(+) create mode 100644 web/app/alerts/page.tsx diff --git a/web/app/alerts/page.tsx b/web/app/alerts/page.tsx new file mode 100644 index 0000000..97f5749 --- /dev/null +++ b/web/app/alerts/page.tsx @@ -0,0 +1,211 @@ +"use client"; + +// /alerts — 모든 가격 알람을 한 화면에서 관리. +// 종목별로 그룹핑, 활성/발사됨 분리, 재무장·삭제·일괄 지우기. +// AlertsToaster 가 글로벌로 폴링하므로 이 페이지는 표시/조작만 담당. + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { alerts, type Alert } from "../../lib/alerts"; + +export default function AlertsPage() { + const [items, setItems] = useState([]); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setHydrated(true); + const refresh = () => setItems(alerts.list()); + refresh(); + return alerts.subscribe(refresh); + }, []); + + // 종목별 그룹. 표시는 최신 등록순(prepend 된 순서) 유지. + const grouped = useMemo(() => { + const m = new Map(); + for (const a of items) { + const g = m.get(a.code) ?? { name: a.name, rows: [] }; + if (!g.name && a.name) g.name = a.name; + g.rows.push(a); + m.set(a.code, g); + } + return Array.from(m.entries()).map(([code, g]) => ({ + code, + name: g.name, + rows: g.rows, + })); + }, [items]); + + const activeCount = items.filter((a) => !a.triggered).length; + const firedCount = items.length - activeCount; + + return ( +
+
+ + ← 홈 + + + 활성 {activeCount} · 발사됨 {firedCount} + +
+ +
+
+

가격 알람

+

+ 브라우저에 저장됩니다. 활성 알람은 60초마다 체크되어 임계값 도달 시 토스트 + + (권한 허용 시) 시스템 알림. +

+
+ {items.length > 0 && ( + + )} +
+ +
+ {!hydrated ? ( +
+ 로딩 중… +
+ ) : items.length === 0 ? ( +
+ 아직 설정된 알람이 없습니다. +
+ 종목 페이지의 가격 알람 카드에서 추가하세요. +
+ ) : ( + + )} + + {hydrated && grouped.length > 0 && ( +
+ {grouped.map((g) => ( + + ))} +
+ )} +
+
+ ); +} + +// 알림 권한이 default 면 안내 + 요청 버튼. +function NotificationHint() { + const [perm, setPerm] = useState( + "unsupported", + ); + useEffect(() => { + if (typeof window === "undefined") return; + if (!("Notification" in window)) { + setPerm("unsupported"); + return; + } + setPerm(Notification.permission); + }, []); + + if (perm === "granted" || perm === "denied" || perm === "unsupported") return null; + return ( +
+ 시스템 알림 권한이 없습니다. 허용하면 브라우저 탭이 닫혀 있어도 알람을 받을 수 있습니다. + +
+ ); +} + +function Group({ + code, + name, + rows, +}: { + code: string; + name?: string; + rows: Alert[]; +}) { + return ( +
+
+ + {name ?? code} + {code} + + {rows.length}개 +
+
    + {rows.map((a) => ( +
  • +
    + + {a.op === "gte" ? "≥" : "≤"} + + + {a.target.toLocaleString()} + + + {new Date(a.createdAt).toLocaleString("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} + + {a.triggered && ( + + 발사됨 + + )} +
    +
    + {a.triggered && ( + + )} + +
    +
  • + ))} +
+
+ ); +} diff --git a/web/components/TopNav.tsx b/web/components/TopNav.tsx index 061a90f..9b10fbb 100644 --- a/web/components/TopNav.tsx +++ b/web/components/TopNav.tsx @@ -151,6 +151,13 @@ export function TopNav() { )} + + 🔔 +