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:
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