fix(stats): 页脚时间标签改成「快照时间」,去掉重复的「数据基准日」

原来四行时间里,前两行说的是同一件事:
「数据基准日:2026-10-24」+「计算于:2026-10-24 14:05:22」。
`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 now 格式化出来的),
两行并列只是把同一个值拆成粗/细两种粒度。

而且「计算于」没有主语:读者站在页脚最底部看到它,会当成「整页数据
算到这一刻」,可今日两项恰恰不在这份快照里 —— 命中缓存时
`withToday` 会用更新的读数替换它们,页面上就出现「标注 14:05、
今日提问却是 22:35 的数」。

改成三行:
  快照时间:     2026-10-24 14:05:22
  最后一条提问: ...
  最后一条回答: ...
「快照只含累计数字与图表、今日两项实时」由下面那条 cacheNote 说明,
不塞进标签里(标签越短越好扫)。词条同步:快照时间 / 快照時間 /
Snapshot: / 集計時刻。

顺带删掉 `Overview.DataDate` 字段:模板不用它之后就没有读者了,
留着它会诱使以后的人再拼出「基准日 + 计算于」这种重复行。

无 schema/接口变更,纯展示层。i18n 有词条数量对等测试,四个语言已
一起改;`go build ./...` 与 i18n / stats / template / templates /
route 测试全绿。
This commit is contained in:
tamakyi
2026-10-24 22:39:47 +08:00
parent 219adefc95
commit af39651734
9 changed files with 91 additions and 15 deletions
+67
View File
@@ -75,3 +75,70 @@
- 脚注「当日 0 点的快照」不实(实际是当天首访/重启时算)→ 改成准确描述。
- DB 故障时 `ovRefresh` 串行 + 30s 超时会让请求排成 N×30s → 加 30s 失败冷却
(`ovFailAt`),`todayNumbers` 失败也盖时间戳,不再每请求打两条 COUNT。
## 30 天折线补纵轴刻度(`cbb8908`)
- `internal/template/chart.go`:`Polyline`/`AreaPath` 两个函数收敛成 `NewLineChart`
返回 `LineChart{HasData,Max,Points,Area,Ticks}`。**坐标/面积/刻度必须一次算**,
分两次各自归一化会让线和刻度错开半格。
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n 向上取整),刻度 上限/一半/0。
1.5 倍只在 mag≥100 时参与(否则算出 7.5 半格);上限恒为偶数 → 中间刻度必为整数。
- 刻度文字**不能放进 SVG**:viewBox 是 `preserveAspectRatio="none"` 横向拉伸的,
文字会被拉扁。改左边一列 HTML 标签(`.tb-line-y` + 绝对定位 `.tb-line-tick`,
top 用 Go 算好的 `TopPct`),横向虚线仍在 SVG 里(`non-scaling-stroke`)。
- 列宽靠看不见的 `.tb-line-ruler`(最宽那条刻度)在流内占位撑开,不写死 em。
- 面积路径改为沿基线 `h-padBottom` 闭合(原来落到 viewBox 底边,会漏进轴标签留白)。
- 全零/空数据只出轴不出线(`HasData`),否则一条贴基线的直线看着像"数据都是 0"。
- `internal/template/chart_test.go`:锁三条不变式 + 0..2000 穷举。
- 视觉自检:900px 亮/暗 + 390px iframe(Windows headless 最小窗宽 ~485px,不能直接缩窗)。
## 取证:`GetHotQuestions` 的 `ORDER BY updated_at DESC`(未改代码)
- **先纠正前提:这个方法全仓零调用点**,只有接口声明 + 实现(`route/` 里 Questions 的
15 个调用方法不含它;`git log -S` 自 baseline 起也没删过调用)。属死代码,线上零影响。
- 连真库实测(临时测试,跑完已删):
- 两条公开已答问题:old 9 天前回答 / new 1 小时前回答 → `GetHotQuestions` 得 `new old`;
- `SetRead(old)` 后 → `old new`(名次被翻);`SetPinned(old)` 同样;
- 换 `ORDER BY COALESCE(answered_at, updated_at) DESC` → 稳定 `new old`;
- old 的 `answered_at` / `updated_at` 相差 216h。
- 机制:`dbutil.Model.UpdatedAt` 是 GORM 约定字段,任何 `Update/Updates/Save` 都会刷新。
所以污染源不止「标记已读」:`SetPinned` / `SetPublic` / `SetPrivate` / `UpdateCensor` 全是。
- **同类残留(仍在线上生效)**:`QuestionStats` 的 `LastAnswerAt = MAX(updated_at)`,
被老的 `/api/v1/site-stats` 用着(首页已不 fetch,但公开接口还在)。实测被推到当下,
比真实最后回答晚 1 小时。新 `/stats` 页已改用 `COALESCE(answered_at, updated_at)`。
- 另:`analytics.go` 的「今日回答数」也用 `updated_at >= since`(注释已承认偏大),未改。
## 已改(`8380fc9`):删死代码 + 修 LastAnswerAt
- 删 `GetHotQuestions`(接口声明 + 实现)。零调用点、唯一实现者 `*questions`、无 fake/mock。
- `QuestionStats.last_a` 改 `MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at))`。
先写测试跑红(偏差 216h)再改绿,回退路径另有一条测试。
- 影响面只有 `/api/v1/site-stats`(`last_answer_at` + `text`),字段名/结构不变,值变准。
- 性能:5 万行实测 7.9ms vs 8.0ms,3 次重复差 −0.5~1.8ms(噪声带内,约 30ns/行);
执行计划仍 Seq Scan + Aggregate,且该接口有 30 分钟缓存。
- 仍未改:`analytics.go:202` 今日回答数用 `updated_at >= since`(置顶会算进"今日回答"),
改它会直接改变 /stats 首页数字,留待用户决定。
## 已改(`219adef`):今日回答数也换 answered_at
- `TodayCounts` 今日回答:`answer <> '' AND updated_at >= ?` → `answered_at >= ?`。
实测:三天前回答的旧问题依次 SetPinned/SetPrivate/SetPublic → today answers 1→2(改前)。
- 口径 = 「今天**首次**被回答的提问数」,与文案「今日回答 / Answered today」一致;
改写回答不重置 answered_at,所以改写不算今日回答(有意)。
- 性能:`answered_at` 无索引 → Seq Scan;但 `updated_at` 本来也没索引,同样是 Seq Scan。
20 万行实测 36.7ms vs 38.0ms(3.5%,噪声级)。**没加索引**,注释里留了
「真慢了再加 idx_questions_answered_at」。
- ⚠️ 顺手发现:`analytics.go` 注释原写「只扫当天数据(走 created_at 索引)」,
但「今日回答」那半句一直不是索引扫(updated_at 无索引)。已改正注释。
- 测试:`TestTodayAnswersIgnoreNonAnswerWrites`(改前红 1 vs 2,改后绿)。
## 已改:/stats 脚注去掉「数据基准日」、「计算于」→「快照时间」
- 用户反馈「计算于」表达怪:它其实只是**累计数字与图表**那份快照的生成时间,
而今日两项在缓存命中时会被 `withToday` 换成更新的读数 —— 放在页脚最底部读起来像「整页时间」。
- 「数据基准日」与它 100% 重复:`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 `now`),
删掉 `stats.foot.dataDate` 词条 + `Overview.DataDate` 字段(模板/预览测试同步改)。
- 页脚从 4 行变 3 行:快照时间 / 最后一条提问 / 最后一条回答;「今日两项实时」由下面
的 `stats.foot.cacheNote` 说明,不塞进标签里。
- 词条:快照时间 / 快照時間 / Snapshot: / 集計時刻(`stats.foot.updated` 改名改值)。
- i18n 有词条数量对等测试,删键必须四个语言一起删。
+17 -2
View File
@@ -40,6 +40,12 @@ db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存
句式用「上月提问量环比 %s%%」(带符号),不要写「比前一个月 -69.6%」。
- 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「0 点快照」),
「今日提问/回答」两项实时(60 秒 TTL,见下)。
- 页脚时间标签因此叫「快照时间」而不是「计算于」:它只覆盖累计与图表,
今日两项会被 `withToday` 换掉。原先另有「数据基准日」一行,与它是 100% 重复
(`Overview.DataDate` 就是 `UpdatedAt` 的日期部分),已删。
- 今日两项的 60s TTL **不要**为了省查询改成 30 分钟:每分钟最多 2 条 COUNT,
20 万行实测 38ms/条 → 最坏 109 秒 DB 时间/天(0.13%),换不到什么;
而 30 分钟会让「(实时)」变成假话、提问者自己看不见刚发的那条。
- 日/周/时分组必须在 SQL 里 `AT TIME ZONE INTERVAL '+08:00'` 显式换算,
不能依赖数据库会话时区 —— 见「时区」一节。
@@ -78,8 +84,17 @@ totals + 字数分布 + 回答耗时合并成一条 SQL(都是同一张表的
站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。
- 折线:`Polyline` / `AreaPath` 模板函数算坐标,SVG `viewBox` + `preserveAspectRatio="none"`
+ `vector-effect: non-scaling-stroke`(否则窄屏描边被压细)。
- 折线:`NewLineChart` 一个函数给出 `{Max, Points, Area, Ticks}`,
SVG `viewBox` + `preserveAspectRatio="none"` + `vector-effect: non-scaling-stroke`
(否则窄屏描边被压细)。
- **坐标 / 面积 / 刻度必须同一次调用算出**:分开算会各按自己看到的峰值归一化,
线和刻度就错开半格。
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n),刻度 = 上限 / 一半 / 0;
上限恒为偶数 → 中间刻度必是整数。1.5 倍只在 mag≥100 时参与(否则是 7.5 半格)。
- 刻度文字放 SVG **外面**:viewBox 横向被拉伸,里面的文字会跟着变扁。
做法是左列绝对定位的 HTML 标签(top 用 Go 算好的 `TopPct`)+ 看不见的占位元素撑列宽。
- 面积沿基线(`h-padBottom`)闭合,别落到 viewBox 底边(那是轴标签的留白)。
- 全零/空数据只出轴不出线,否则贴基线的直线看着像「数据都是 0」。
- 柱状 / 分布条:`style="height:{{printf "%.1f" $pct}}%"`,CSS 直接用百分比。
- 环形:`<circle pathLength="100" stroke-dasharray="{{pct}} 100">` + `rotate(-90)`,
不用算周长。