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>
This commit is contained in:
56
web/lib/views.ts
Normal file
56
web/lib/views.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// 종목 조회 카운터 클라이언트 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;
|
||||
}
|
||||
Reference in New Issue
Block a user