Files
stock_chart_site/web/app/alerts/page.tsx
claude-owner d5acf79e05 fix(alerts): /alerts 안내문 — 탭이 열려 있어야 동작함을 명시
리뷰어 지적: 기존 문구가 '탭이 닫혀 있어도 알람'으로 오해되어
실제 AlertsToaster 의 60초 폴링 동작(탭 열려 있을 때만)과 불일치.

- 메인 설명: '웹앱이 열려 있는 동안 60초마다 체크' + '탭 닫히면 체크 X' 명시
- NotificationHint: '다른 창을 보고 있을 때도 OS 알림 (탭 열려 있어야)'로 정정

Service Worker + Push API 미구현 — 로컬 localStorage 기반 앱이라 VAPID/푸시
백엔드는 과한 인프라. 문구로 한계를 정확히 표시하는 쪽으로 정정.
2026-05-28 18:33:07 +09:00

213 lines
7.3 KiB
TypeScript

"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<Alert[]>([]);
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<string, { name?: string; rows: Alert[] }>();
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 (
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-4 flex items-center justify-between gap-3">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<span className="text-[11px] text-zinc-500">
{activeCount} · {firedCount}
</span>
</div>
<div className="flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-zinc-50"> </h1>
<p className="mt-1 text-sm text-zinc-400">
. <b className="text-zinc-300"> </b>{" "}
60 + ( )
. .
</p>
</div>
{items.length > 0 && (
<button
type="button"
onClick={() => {
if (confirm("모든 알람을 삭제하시겠어요?")) alerts.clear();
}}
className="shrink-0 rounded-md border border-zinc-700 px-2 py-1 text-[11px] text-zinc-400 hover:border-rose-500/40 hover:text-rose-300"
>
</button>
)}
</div>
<div className="mt-6">
{!hydrated ? (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
) : items.length === 0 ? (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-6 text-center text-sm text-zinc-500">
.
<br />
<b className="text-amber-300"> </b> .
</div>
) : (
<NotificationHint />
)}
{hydrated && grouped.length > 0 && (
<div className="mt-4 space-y-3">
{grouped.map((g) => (
<Group key={g.code} code={g.code} name={g.name} rows={g.rows} />
))}
</div>
)}
</div>
</main>
);
}
// 알림 권한이 default 면 안내 + 요청 버튼.
function NotificationHint() {
const [perm, setPerm] = useState<NotificationPermission | "unsupported">(
"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 (
<div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-200">
<span> . OS ( ).</span>
<button
type="button"
onClick={async () => {
try {
const p = await Notification.requestPermission();
setPerm(p);
} catch {
// 무시
}
}}
className="shrink-0 rounded-md border border-amber-400/50 bg-amber-500/10 px-2 py-1 text-[11px] hover:bg-amber-500/20"
>
</button>
</div>
);
}
function Group({
code,
name,
rows,
}: {
code: string;
name?: string;
rows: Alert[];
}) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<Link
href={`/${code}`}
className="flex items-baseline gap-2 text-sm text-zinc-100 hover:text-white"
>
<span className="font-medium">{name ?? code}</span>
<span className="text-[10px] text-zinc-500">{code}</span>
</Link>
<span className="text-[10px] text-zinc-500">{rows.length}</span>
</div>
<ul className="divide-y divide-zinc-800">
{rows.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-2 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-xs">
<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>
<span className="ml-1 text-[10px] text-zinc-500">
{new Date(a.createdAt).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</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>
);
}