Files
stock_chart_site/web/lib/views.ts
claude-owner ecf8b9112b feat(views): anonymous daily view counter with social-proof badge
- New table symbol_views_daily(code, view_date, views) via migration 004.
- POST /api/views/{code} UPSERTs and returns today_views; GET returns
  today + last_7d + trend (last 7 days).
- Client dedupes via localStorage (views:logged map keyed by KST date) so a
  given browser counts once per day per code; refresh/extra tabs don't inflate.
- PriceHero shows "오늘 N명이 봤어요" pill when viewsToday > 0.

Read-only social proof — Toss 의 종목 상세에서 익명 카운트로 진입 신호를 주는 패턴.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:56:36 +09:00

57 lines
2.0 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번 카운트될 뿐. */
}
}
/** 오늘 처음 보는 code 면 true, 이미 봤으면 false. true 인 경우 mark 까지 수행. */
export function shouldRecordView(code: string): boolean {
const today = kstToday();
const logged = readLogged();
if (logged[code] === today) return false;
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);
}
return true;
}