feat(rank): /rank 页热门文档 tab + /docs 埋点#276
Conversation
- 新增 DocsPageViewTracker 客户端组件,usePathname+useEffect 监听路由 - sessionStorage 去重,同一会话同一 path 不重复上报 - POST /api/analytics,携带 x-satoken(有 token 时),匿名用户 userId 由后端解析为 null - 在 app/docs/layout.tsx 中挂载
- RankTabs 客户端组件:Contributors | Hot Docs 两 tab,URL query ?tab= 保存状态 - HotDocsTab 组件:7d/30d/all time 窗口切换,fetch /api/v1/analytics/top-docs - useReducer 避免 setState 在 effect 中同步调用的 lint 报错 - 加载/错误/空状态(空时显示「数据积累中…」) - 设计语言:font-serif font-black、border-b-4、hard-shadow-hover 贴合 Newspaper 风格 - rank/page.tsx 改造为 server+client 混合,Suspense 包裹 RankTabs
- next.config.mjs 新增 /analytics/:path* rewrite 转发至后端 BACKEND_URL - 前端改用同源 /analytics/top-docs 请求,NEXT_PUBLIC_BACKEND_URL 作为可选覆盖(默认空串走代理),避免浏览器跨域 - 解包后端 ApiResponse 取 data 字段再渲染,之前直接当 HotDoc[] 用导致列表不显示
- DocsPageViewTracker 说明埋点目的、sessionStorage 去重策略与静默失败 - RankTabs 解释为什么状态走 URL query 而不是 component state
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR adds analytics tracking for docs page views and expands the /rank page into a tabbed UI that includes a new “Hot Docs” leaderboard backed by a backend analytics endpoint (proxied via Next.js rewrites).
Changes:
- Add
/analytics/:path*rewrite in Next.js to proxy analytics endpoints toBACKEND_URL. - Add
/ranktab shell (Contributors / Hot Docs) with URL-synced state (tab,window) and a new Hot Docs leaderboard that fetches from/analytics/top-docs. - Add a client-side docs page-view tracker mounted under
app/docs/layout.tsx, postingpage_viewevents toPOST /api/analyticswith session-level dedupe.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
next.config.mjs |
Proxies /analytics/* to backend to support Hot Docs data fetch. |
app/rank/page.tsx |
Converts /rank to SSR + tab shell with initial state derived from query params. |
app/docs/layout.tsx |
Mounts the docs page-view tracking side-effect component. |
app/components/rank/RankTabs.tsx |
Client tab controller using query params + router navigation. |
app/components/rank/HotDocsTab.tsx |
Fetches and renders the Hot Docs leaderboard with window switching. |
app/components/DocsPageViewTracker.tsx |
Implements sessionStorage-deduped page_view analytics reporting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const searchParams = useSearchParams(); | ||
| const activeTab = (searchParams.get("tab") as Tab) ?? initialTab; | ||
| const activeWindow = (searchParams.get("window") as Window) ?? initialWindow; | ||
|
|
There was a problem hiding this comment.
useSearchParams().get() returns an arbitrary string | null, but the code casts it to Tab/Window without validation. If the URL contains an unexpected value (e.g. ?tab=foo or ?window=1d), activeTab/activeWindow become invalid at runtime, which can result in rendering no tab content and/or passing an unsupported window to HotDocsTab.
Consider explicitly whitelisting allowed values and falling back to initialTab/initialWindow (or a safe default) when the query value is not recognized.
| // 同会话同 path 已上报则跳过,避免刷新/快速切换重复计数 | ||
| const key = `pv_reported:${pathname}`; | ||
| if (sessionStorage.getItem(key)) return; | ||
|
|
||
| sessionStorage.setItem(key, "1"); |
There was a problem hiding this comment.
sessionStorage.getItem/setItem can throw (e.g. storage disabled / quota exceeded). Since this effect isn’t wrapped in try/catch, a storage exception will surface as a client error even though the tracker is intended to be non-intrusive.
Consider wrapping the sessionStorage access in try/catch and, if it fails, skip dedupe and continue (or fall back to an in-memory flag).
| const token = | ||
| typeof window !== "undefined" ? localStorage.getItem("satoken") : null; |
There was a problem hiding this comment.
localStorage.getItem("satoken") can throw in some browser modes (e.g. storage disabled / private mode). To keep this tracker truly silent, consider wrapping the token read in try/catch and just omitting the header if storage is unavailable.
| const token = | |
| typeof window !== "undefined" ? localStorage.getItem("satoken") : null; | |
| let token: string | null = null; | |
| if (typeof window !== "undefined") { | |
| try { | |
| token = localStorage.getItem("satoken"); | |
| } catch { | |
| token = null; | |
| } | |
| } |
Copilot CR #276: - RankTabs: useSearchParams 返回 string|null,之前直接 as Tab/Window 没校验, ?tab=foo 或 ?window=1d 会让下游组件拿到非法值导致空白渲染。 加白名单 + type guard,非法时 fallback 到 initial* - DocsPageViewTracker: sessionStorage / localStorage 包 try/catch Safari 隐私模式或存储禁用时不崩,降级到继续上报但不去重
接前一 commit,补上之前落下的 RankTabs 改动:useSearchParams 返回 string|null,非法值(?tab=foo、?window=1d)之前被强转 as Tab/Window 后 导致下游 HotDocsTab 收到非法 window 或 tab 所有分支都不命中导致空白渲染。 加 type guard + fallback 到 initial*
Summary
/docs页补 page_view 埋点(sessionStorage 同会话同 path 去重,失败静默)/rank页加 tab 切换:Contributors(原静态贡献者榜保留)/ Hot Docs(新增热门文档榜)数据源
当前榜单数据来自 GA4 Data API(后端 PR 见 involutionhell-backend#feature/docs-heat-leaderboard)。`/docs` 埋点的自家 AnalyticsEvent 表并行积累,供后续切换数据源时使用。
Test plan
注意
Contributors tab 展示的 "GitHub User " 是 `generated/site-leaderboard.json` 的现有数据问题(GitHub API 没拿到真实 name),与本 PR 无关,已记下后续单独修复。