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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user