Skip to content

feat(rank): /rank 页热门文档 tab + /docs 埋点#276

Merged
longsizhuo merged 6 commits intomainfrom
feature/docs-heat-leaderboard
Apr 14, 2026
Merged

feat(rank): /rank 页热门文档 tab + /docs 埋点#276
longsizhuo merged 6 commits intomainfrom
feature/docs-heat-leaderboard

Conversation

@longsizhuo
Copy link
Copy Markdown
Member

Summary

  • /docs 页补 page_view 埋点(sessionStorage 同会话同 path 去重,失败静默)
  • /rank 页加 tab 切换:Contributors(原静态贡献者榜保留)/ Hot Docs(新增热门文档榜)
  • Hot Docs 支持 7D / 30D / All Time 三档窗口切换,状态通过 URL query 同步以便分享
  • 同源 `/analytics/*` 经 next.config rewrite 代理到后端 `BACKEND_URL`,默认 `http://localhost:8080\`

数据源

当前榜单数据来自 GA4 Data API(后端 PR 见 involutionhell-backend#feature/docs-heat-leaderboard)。`/docs` 埋点的自家 AnalyticsEvent 表并行积累,供后续切换数据源时使用。

Test plan

  • `pnpm lint` 通过(新代码 0 errors)
  • `pnpm typecheck` 新文件 0 errors
  • `pnpm test` 全过
  • 本地 3010 端口联调后端 8081,Hot Docs tab 正常渲染带中文标题的文档
  • Reviewer: 确认 `/rank?tab=hot&window=7d` 分享链接能定位到正确状态

注意

Contributors tab 展示的 "GitHub User " 是 `generated/site-leaderboard.json` 的现有数据问题(GitHub API 没拿到真实 name),与本 PR 无关,已记下后续单独修复。

- 新增 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
Copilot AI review requested due to automatic review settings April 14, 2026 18:04
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
involutionhell-github-io Ready Ready Preview, Comment Apr 14, 2026 7:46pm
website-preview Ready Ready Preview, Comment Apr 14, 2026 7:46pm

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to BACKEND_URL.
  • Add /rank tab 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, posting page_view events to POST /api/analytics with 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.

Comment on lines +35 to +38
const searchParams = useSearchParams();
const activeTab = (searchParams.get("tab") as Tab) ?? initialTab;
const activeWindow = (searchParams.get("window") as Window) ?? initialWindow;

Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +27
// 同会话同 path 已上报则跳过,避免刷新/快速切换重复计数
const key = `pv_reported:${pathname}`;
if (sessionStorage.getItem(key)) return;

sessionStorage.setItem(key, "1");
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +31
const token =
typeof window !== "undefined" ? localStorage.getItem("satoken") : null;
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 uses AI. Check for mistakes.
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*
@longsizhuo longsizhuo merged commit a6ed407 into main Apr 14, 2026
8 checks passed
@longsizhuo longsizhuo deleted the feature/docs-heat-leaderboard branch April 14, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants