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>