Files
stock_chart_site/web/lib/views.ts
claude-owner a4a4a7f98c 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>
2026-05-29 00:01:43 +09:00

62 lines
2.2 KiB
TypeScript

// 종목 조회 카운터 클라이언트 dedupe.
//
// 서버는 받은 POST 횟수만큼 +1. 같은 사용자가 페이지를 새로고침할 때마다 카운트되면
// 인플레됨 → 클라이언트가 localStorage 에 (code, KST today) 마크를 남겨 하루 1회만 POST.
// 새 탭/시크릿 모드는 새 사용자로 간주 (브라우저 한정 dedupe — 비로그인 앱이라 한계).
const STORAGE_KEY = "views:logged";
type LoggedMap = Record<string, string>; // code → "YYYY-MM-DD"
function kstToday(): string {
// KST = UTC+9. naive: Date 가 시스템 TZ 영향을 받으므로 UTC 기준 +9 로 직접 계산.
const now = new Date();
const utcMs = now.getTime() + now.getTimezoneOffset() * 60_000;
const kst = new Date(utcMs + 9 * 60 * 60_000);
const y = kst.getFullYear();
const m = String(kst.getMonth() + 1).padStart(2, "0");
const d = String(kst.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function readLogged(): LoggedMap {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as LoggedMap) : {};
} catch {
return {};
}
}
function writeLogged(m: LoggedMap): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(m));
} catch {
/* quota 초과 등 — 무시. dedupe 실패 시 최악으로 한 사용자가 N번 카운트될 뿐. */
}
}
/** 오늘 이미 카운트된 종목인지. 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();
logged[code] = today;
// 한도: 너무 커지면 오래된 항목 제거. 단순히 100개로 잘라냄.
const entries = Object.entries(logged);
if (entries.length > 100) {
const trimmed: LoggedMap = Object.fromEntries(entries.slice(-100));
writeLogged(trimmed);
} else {
writeLogged(logged);
}
}