- 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>
57 lines
2.0 KiB
TypeScript
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;
|
|
}
|