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:
claude-owner
2026-05-28 23:56:36 +09:00
parent ef127ae2e8
commit ecf8b9112b
7 changed files with 248 additions and 2 deletions

View File

@@ -21,6 +21,7 @@ import { SymbolSidebar } from "../../components/SymbolSidebar";
import { TradingValuePanel } from "../../components/TradingValuePanel";
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
import { recent } from "../../lib/recent";
import { shouldRecordView } from "../../lib/views";
export default function CodePage({ params }: { params: { code: string } }) {
const { code } = params;
@@ -28,6 +29,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
const [period, setPeriod] = useState<Period>("3M");
const [viewsToday, setViewsToday] = useState<number | null>(null);
const spec = periodSpec(period);
const isIntraday = spec.interval === "10m";
@@ -37,6 +39,29 @@ export default function CodePage({ params }: { params: { code: string } }) {
recent.push(code);
}, [code]);
// 조회 카운터.
// - 같은 (code, KST today) 는 localStorage 마크로 dedupe → POST 1회.
// - dedupe 실패 (스토리지 잠금 등) 해도 GET 으로 today_views 는 조회.
// - POST 응답이 today_views 를 주므로 별도 GET 안 해도 됨 (한 라운드트립으로 끝).
// - 이미 봤던 종목이면 GET 만.
useEffect(() => {
let alive = true;
const apply = (n: number) => {
if (alive) setViewsToday(n);
};
if (shouldRecordView(code)) {
api.recordView(code).then((r) => apply(r.today_views)).catch(() => {
// POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌).
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
});
} else {
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
}
return () => {
alive = false;
};
}, [code]);
useEffect(() => {
let alive = true;
setErr(null);
@@ -140,6 +165,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
prev={prev}
asOf={asOf}
series={sparkSeries}
viewsToday={viewsToday}
/>
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
<div>

View File

@@ -15,9 +15,20 @@ type Props = {
asOf?: string | null;
// 헤더 우측 sparkline 용 종가 시계열 (오래된 → 최신 순). 비어 있으면 미표시.
series?: number[];
// social-proof — "오늘 N명이 봤어요" 배지. null/0 이면 숨김.
viewsToday?: number | null;
};
export function PriceHero({ name, code, market, current, prev, asOf, series }: Props) {
export function PriceHero({
name,
code,
market,
current,
prev,
asOf,
series,
viewsToday,
}: Props) {
const change = current != null && prev != null ? current - prev : null;
const pct = change != null && prev ? (change / prev) * 100 : null;
const tone =
@@ -49,7 +60,17 @@ export function PriceHero({ name, code, market, current, prev, asOf, series }: P
</span>
)}
</div>
{asOf && <div className="mt-1 text-xs text-zinc-500"> {asOf}</div>}
<div className="mt-1 flex items-center gap-2 text-xs text-zinc-500">
{asOf && <span> {asOf}</span>}
{viewsToday != null && viewsToday > 0 && (
<span className="rounded-full border border-zinc-700/70 bg-zinc-900/60 px-2 py-0.5 text-[11px] text-zinc-300">
<span className="tabular-nums text-zinc-100">
{viewsToday.toLocaleString()}
</span>
</span>
)}
</div>
</div>
{series && series.length >= 2 && (

View File

@@ -352,4 +352,28 @@ export const api = {
getJson<OrderbookResponse>(`/api/orderbook/${encodeURIComponent(code)}`),
fundamentals: (code: string) =>
getJson<FundamentalsResponse>(`/api/fundamentals/${encodeURIComponent(code)}`),
recordView: (code: string) =>
getJson<RecordViewResponse>(`/api/views/${encodeURIComponent(code)}`, {
method: "POST",
}),
views: (code: string) =>
getJson<ViewsResponse>(`/api/views/${encodeURIComponent(code)}`),
};
export type RecordViewResponse = {
ok: boolean;
code?: string;
today_views: number;
};
export type ViewsTrendPoint = {
date: string;
views: number;
};
export type ViewsResponse = {
code: string;
today_views: number;
last_7d_views: number;
trend: ViewsTrendPoint[];
};

56
web/lib/views.ts Normal file
View 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;
}