Files
tamabox/.workbuddy/memory/topics/stats.md
T
tamakyi a5441c99fd feat(stats): 新增公开数据页 /stats,入口收敛到页脚
首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /stats:
今日实时数、概览六格、近 30 天折线与 12 个月柱状、三个占比环、
7×24 时段热力图、提问字数与回答耗时分布。

- 聚合在 internal/db/analytics.go(SUM(CASE WHEN),方言差异收敛在 analyticsDialectFor)
- 结果按自然日缓存,跨日重算一次后全站共用;重算失败降级返回上一份快照
- 图表零前端依赖:服务端算坐标的 SVG + CSS grid,禁用 JS 页面仍完整
- 环比取「上月 vs 上上月」:本月未过完,否则每月 1 号会得出 -95%
- 入口只在页脚「社区规则」与「找站长」之间;route.Home 不再计算统计
- 补 questions(created_at) 索引;启动后异步预热当天快照
- 测试:analytics 连真库验证软删除不计入、分桶下标、DOW 对齐
2026-10-24 00:57:08 +08:00

3.9 KiB
Raw Blame History

全站数据页(/stats)

2026-10 新增。完整数据在 /stats(公开、只读、纯服务端渲染)。

入口只有一处:页脚「社区规则 | 数据 | 找站长」中间那格 (templates/base/footer.html,词条 site.footer.stats)。 首页那张「站点统计」卡与后来的入口卡都已下线(用户明确要求首页不再放), route.Home 也不再调 stats.Get;/api/v1/site-stats 接口保留未动。 新增入口不要再往首页加——那会让两处数字需要同步维护。

分层

db.Analytics.SiteAnalytics(ctx, now)   // 纯 SQL 聚合,无缓存
  → stats.OverviewData(ctx)            // 按自然日缓存(每天只算一次)
    → stats.NewPage(lang, ov)          // i18n 标签 + 百分比 + 文字洞察
      → route.Stats → templates/stats.html
  • internal/db/analytics.go:SiteAnalyticsStore。用 SUM(CASE WHEN) 而不是 COUNT(*) FILTER(MySQL 没有 FILTER)。方言差异(to_char/DATE_FORMAT、 EXTRACT(DOW)/DAYOFWEEK-1、EPOCH/TIMESTAMPDIFF)收敛在 analyticsDialectFor。
  • internal/stats/overview.go:缓存 key 是自然日字符串,不是 TTL —— 换日即失效, 当天重算一次后全站共用。重算串行化(ovRefresh),失败时返回上一次成功的旧值。
  • internal/stats/page.go:Page 嵌 *Overview。桶标签在 Go 侧拼好,别在模板里 printf "stats.len.%s" —— 缺词条会把 key 印到页面上。
  • internal/cmd/web.go:启动后 3s 异步预热,把当天首访的等待挪出请求路径。

口径(改之前先看)

  • 已回答 = answer <> '';已读 = is_read OR answer <> ''。
  • 匿名 = asker_user_id = 0;登录 = 总数 − 匿名。
  • 回答耗时用 updated_at - created_at,但置顶等写操作也会刷新 updated_at, 所以只对外给「24 小时内占比」这种粗结论,不宣称中位数。
  • 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。
  • 累计与图表是当日 0 点快照,「今日提问/回答」两项实时。页面底部已写明。

图表实现(零前端库)

站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。

  • 折线:Polyline / AreaPath 模板函数算坐标,SVG viewBox + preserveAspectRatio="none"
    • vector-effect: non-scaling-stroke(否则窄屏描边被压细)。
  • 柱状 / 分布条:style="height:{{printf "%.1f" $pct}}%",CSS 直接用百分比。
  • 环形:<circle pathLength="100" stroke-dasharray="{{pct}} 100"> + rotate(-90), 不用算周长。
  • 热力图:CSS grid 24 列 + aspect-ratio:1,档位用 --tb-primary 的 opacity 分 4 档 (连续透明度在小方块上肉眼分不出来)。
  • 7×24 的 DOW 下标:PG EXTRACT(DOW) 与 Go time.Weekday() 都是周日=0; 展示顺序(周一起)在 weekdayOrder 里转。

预览与验证

  • TEST_PREVIEW=1 go test ./templates -run TestGenPreview 出 outputs/preview-stats-{zh-CN,en}-{light,dark}.html。
  • writePreview 会把 href="/static/ 换成本地相对路径 —— 不换的话预览页走 file://, :root 令牌全缺,var(--tb-*) 变无效值:图表全透明、卡片没边框(一度以为是渲染 bug)。
  • internal/db/analytics_test.go 连真库跑(DB_TYPE=postgres ...),验证软删除不计入、 各分桶下标、DOW 对齐。对着 mock 断言等于没验证。
  • 窄屏别用 chrome --window-size=390:Windows headless 最小窗宽约 485,实测 innerWidth=485, 截图再裁到 390 就成了假的"溢出"。用 390px 宽 iframe 包一层再截。

隐私红线

公开页只给聚合数字。不显示 IP、不显示单条内容、不做箱主排行(会招骚扰)。 「收到过提问的箱子」这类计数可以,具体是谁不行。