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:
@@ -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 有词条数量对等测试,删键必须四个语言一起删。
|
||||
|
||||
@@ -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)`,
|
||||
不用算周长。
|
||||
|
||||
@@ -1029,8 +1029,7 @@ var enDict = map[string]string{
|
||||
"stats.weekday.6": "Saturday",
|
||||
"stats.weekday.7": "Sunday",
|
||||
|
||||
"stats.foot.dataDate": "Data as of",
|
||||
"stats.foot.updated": "computed at",
|
||||
"stats.foot.updated": "Snapshot:",
|
||||
"stats.foot.prevMonth": "Last question:",
|
||||
"stats.foot.prevAnswer": "Last answer:",
|
||||
"stats.foot.privacy": "All figures are aggregates; no individual question, asker, or source address is shown.",
|
||||
|
||||
@@ -1028,8 +1028,7 @@ var jaDict = map[string]string{
|
||||
"stats.weekday.6": "土曜日",
|
||||
"stats.weekday.7": "日曜日",
|
||||
|
||||
"stats.foot.dataDate": "集計基準日:",
|
||||
"stats.foot.updated": "算出時刻:",
|
||||
"stats.foot.updated": "集計時刻:",
|
||||
"stats.foot.prevMonth": "最後の質問:",
|
||||
"stats.foot.prevAnswer": "最後の回答:",
|
||||
"stats.foot.privacy": "数値はすべて集計値です。個々の質問内容・質問者の身元・送信元アドレスは表示しません。",
|
||||
|
||||
@@ -1029,8 +1029,7 @@ var zhCNDict = map[string]string{
|
||||
"stats.weekday.6": "周六",
|
||||
"stats.weekday.7": "周日",
|
||||
|
||||
"stats.foot.dataDate": "数据基准日:",
|
||||
"stats.foot.updated": "计算于:",
|
||||
"stats.foot.updated": "快照时间:",
|
||||
"stats.foot.prevMonth": "最后一条提问:",
|
||||
"stats.foot.prevAnswer": "最后一条回答:",
|
||||
"stats.foot.privacy": "所有数字均为聚合结果,不展示单条提问内容、提问者身份或来源地址。",
|
||||
|
||||
@@ -1028,8 +1028,7 @@ var zhTWDict = map[string]string{
|
||||
"stats.weekday.6": "週六",
|
||||
"stats.weekday.7": "週日",
|
||||
|
||||
"stats.foot.dataDate": "數據基準日:",
|
||||
"stats.foot.updated": "計算於:",
|
||||
"stats.foot.updated": "快照時間:",
|
||||
"stats.foot.prevMonth": "最後一則提問:",
|
||||
"stats.foot.prevAnswer": "最後一則回答:",
|
||||
"stats.foot.privacy": "所有數字均為彙總結果,不展示單則提問內容、提問者身分或來源位址。",
|
||||
|
||||
@@ -65,8 +65,10 @@ type Overview struct {
|
||||
ReplyBins [6]int64
|
||||
|
||||
// 元信息
|
||||
//
|
||||
// UpdatedAt 是这份快照的生成时间,只覆盖累计数字与图表;今日两项在缓存命中时
|
||||
// 会被 withToday 换成更晚的读数,所以它不能当成「整页数据的时间」来展示。
|
||||
UpdatedAt string
|
||||
DataDate string
|
||||
}
|
||||
|
||||
// DailyPoint 某一天的提问量。
|
||||
@@ -300,7 +302,6 @@ func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview {
|
||||
HeatmapDays: raw.HeatmapDays,
|
||||
LengthBins: raw.LengthBins,
|
||||
ReplyBins: raw.ReplyBins,
|
||||
DataDate: now.Format("2006-01-02"),
|
||||
}
|
||||
if v.Public < 0 {
|
||||
v.Public = 0
|
||||
|
||||
@@ -613,8 +613,6 @@
|
||||
|
||||
<div class="tb-dash-foot">
|
||||
<div class="tb-foot-times">
|
||||
<span>{{t $.LangCode "stats.foot.dataDate"}}</span>
|
||||
<span>{{ .DataDate }}</span>
|
||||
<span>{{t $.LangCode "stats.foot.updated"}}</span>
|
||||
<span>{{ .UpdatedAt }}</span>
|
||||
<span>{{t $.LangCode "stats.foot.prevMonth"}}</span>
|
||||
|
||||
@@ -243,7 +243,6 @@ func writeStatsPreviews(t *testing.T, outDir string) {
|
||||
Heatmap: heatmap, HeatmapMax: 812, HeatmapDays: 180,
|
||||
LengthBins: [6]int64{8200, 14200, 16800, 9200, 6100, 1821},
|
||||
ReplyBins: [6]int64{4200, 7100, 8400, 6200, 3400, 1908},
|
||||
DataDate: now.Format("2006-01-02"),
|
||||
UpdatedAt: now.Format("2006-01-02") + " 00:07:12",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user