feat(targets): price target/stop simulator with chart overlay lines

- New lib/targets.ts: per-code localStorage (key `targets:<code>`) with
  read/write/clear; rejects non-positive/non-finite values.
- New PriceTargets sidebar panel: 목표가/손절가 inputs, live %-from-current
  preview (rose=up, sky=down), save/clear actions.
- StockChart accepts optional `targets` prop and draws dashed horizontal
  price lines on the candle series (rose=target, sky=stop) with axis labels.
- Page loads saved targets on mount/code-change and re-renders chart on save.

Also fixes view-counter dedupe caveat from reviewer: split into
wasRecordedToday() + markRecorded(); the page now marks localStorage only
after POST /api/views succeeds, so a failed POST stays retryable on the
next visit instead of being silently swallowed for the day.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-owner
2026-05-29 00:01:43 +09:00
parent ecf8b9112b
commit a4a4a7f98c
5 changed files with 279 additions and 12 deletions

49
web/lib/targets.ts Normal file
View File

@@ -0,0 +1,49 @@
// 종목별 목표가/손절가 저장 (브라우저 한정 — 비로그인 앱).
//
// 키: `targets:<code>` → { target?: number, stop?: number, savedAt: ISO }
// 차트 오버레이 라인과 손익 미리보기에 사용. 알림 없음 (그건 AlertsPanel 담당).
const PREFIX = "targets:";
export type Targets = {
target?: number | null; // 익절가 (선택)
stop?: number | null; // 손절가 (선택)
savedAt?: string;
};
export function readTargets(code: string): Targets {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(`${PREFIX}${code}`);
return raw ? (JSON.parse(raw) as Targets) : {};
} catch {
return {};
}
}
export function writeTargets(code: string, t: Targets): void {
if (typeof window === "undefined") return;
// 둘 다 비어 있으면 키 자체를 지움 → 빈 객체 잔여물 제거.
const cleaned: Targets = {};
if (t.target != null && Number.isFinite(t.target)) cleaned.target = t.target;
if (t.stop != null && Number.isFinite(t.stop)) cleaned.stop = t.stop;
try {
if (cleaned.target == null && cleaned.stop == null) {
window.localStorage.removeItem(`${PREFIX}${code}`);
return;
}
cleaned.savedAt = new Date().toISOString();
window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(cleaned));
} catch {
/* quota 초과 등 — 무시 */
}
}
export function clearTargets(code: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(`${PREFIX}${code}`);
} catch {
/* ignore */
}
}

View File

@@ -38,11 +38,17 @@ function writeLogged(m: LoggedMap): void {
}
}
/** 오늘 처음 보는 code 면 true, 이미 봤으면 false. true 인 경우 mark 까지 수행. */
export function shouldRecordView(code: string): boolean {
/** 오늘 이미 카운트된 종목인지. POST 호출 전 dedupe 게이트. */
export function wasRecordedToday(code: string): boolean {
const today = kstToday();
const logged = readLogged();
return logged[code] === today;
}
/** POST 성공 이후 호출. 실패 시 마크하지 않아 다음 방문에 재시도 가능. */
export function markRecorded(code: string): void {
const today = kstToday();
const logged = readLogged();
if (logged[code] === today) return false;
logged[code] = today;
// 한도: 너무 커지면 오래된 항목 제거. 단순히 100개로 잘라냄.
const entries = Object.entries(logged);
@@ -52,5 +58,4 @@ export function shouldRecordView(code: string): boolean {
} else {
writeLogged(logged);
}
return true;
}