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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
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