fix(stats): 环比句点明月份与条数,不再写「上月…环比 -75%」

「上月提问量环比 -75.0%」这句要读者做两次翻译:先把「上月」翻成具体月份,
再把「环比」翻成「比上上月」。两个相对词叠在一起,站长本人看到就以为
是「上月同期 vs 本月同期」—— 而它其实比的是两个完整自然月。

  改前:上月提问量环比 -75.0%。
  改后:9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。

口径一个字没改(仍是上月 vs 上上月,本月没过完不参与),只是把月份和条数
写进句子:读者不需要知道「环比」的定义也能判断这句话在说什么,还能顺手
核对百分比(5620/4890 确实是 +14.9%)。

实现:
- `monthText` 按语言出月份名 —— CJK 一律「9 月」,英文要 "September"
  ("month 9" 不是英语的说法)。不带年份:相比的两个月相邻。
- 一个词条拆成 momUp / momDown / momFlat:方向词塞不进同一个模板,
  四种语言里「多了 / 少了」的位置和词形都不一样。
- `|pct| < 0.05` 走持平,否则会出现「多了 0.0%」。

测试:`TestInsightMoMNamesBothMonths` 锁住「句子里同时有两个月份名和两个条数」,
覆盖上升/下降/持平;`TestMonthText` 锁语言差异。

顺手修预览数据:`zz_preview_test.go` 的 `GrowthMoM` 原来写死 12.3,和它造的
monthly 对不上。以前句子里只有百分比无所谓,现在会连条数一起印出来,
截图就成了「5,620 比 4,890 多了 12.3%」这种假证据,改成从数据算。
This commit is contained in:
tamakyi
2026-10-25 09:27:57 +08:00
parent 79199ac3cd
commit a3cf99a8da
9 changed files with 129 additions and 8 deletions
+3 -1
View File
@@ -974,7 +974,9 @@ var enDict = map[string]string{
"stats.insight.daily30": "%s questions in the last 30 days, about %.1f per day.",
"stats.insight.peak": "Busiest day was %s with %s questions.",
"stats.insight.busiest": "Questions cluster around %s %d:00.",
"stats.insight.mom": "Month-over-month change for last month: %s%%.",
"stats.insight.momDown": "%s had %s questions; %s had %s, down %.1f%%.",
"stats.insight.momFlat": "%s had %s questions; %s had %s, unchanged.",
"stats.insight.momUp": "%s had %s questions; %s had %s, up %.1f%%.",
"stats.insight.fastReply": "%s%% of answered questions got a reply within 24 hours.",
"stats.insight.private": "%s%% of questions were sent privately.",
+3 -1
View File
@@ -973,7 +973,9 @@ var jaDict = map[string]string{
"stats.insight.daily30": "直近 30 日で %s 件の質問、1 日あたり約 %.1f 件。",
"stats.insight.peak": "最も多かった日は %s で %s 件でした。",
"stats.insight.busiest": "質問は %s %d 時ごろに集中しています。",
"stats.insight.mom": "先月の質問数は前月比 %s%%。",
"stats.insight.momDown": "%sは %s 件、%sは %s 件で %.1f%% 減少しました。",
"stats.insight.momFlat": "%sは %s 件、%sは %s 件で変わりません。",
"stats.insight.momUp": "%sは %s 件、%sは %s 件で %.1f%% 増加しました。",
"stats.insight.fastReply": "回答済みのうち %s%% が 24 時間以内に回答されています。",
"stats.insight.private": "質問の %s%% は非公開が選ばれています。",
+3 -1
View File
@@ -974,7 +974,9 @@ var zhCNDict = map[string]string{
"stats.insight.daily30": "近 30 天共收到 %s 条提问,日均 %.1f 条。",
"stats.insight.peak": "最忙的一天是 %s,收到 %s 条提问。",
"stats.insight.busiest": "提问最集中在%s %d 点前后。",
"stats.insight.mom": "上月提问量环比 %s%%。",
"stats.insight.momDown": "%s收到 %s 条提问,比 %s的 %s 条少了 %.1f%%。",
"stats.insight.momFlat": "%s收到 %s 条提问,与 %s的 %s 条持平。",
"stats.insight.momUp": "%s收到 %s 条提问,比 %s的 %s 条多了 %.1f%%。",
"stats.insight.fastReply": "已回答的提问中,%s%% 在 24 小时内拿到了回复。",
"stats.insight.private": "%s%% 的提问选择了私密(不公开)。",
+3 -1
View File
@@ -973,7 +973,9 @@ var zhTWDict = map[string]string{
"stats.insight.daily30": "近 30 天共收到 %s 則提問,日均 %.1f 則。",
"stats.insight.peak": "最忙的一天是 %s,收到 %s 則提問。",
"stats.insight.busiest": "提問最集中在%s %d 點前後。",
"stats.insight.mom": "上月提問量環比 %s%%。",
"stats.insight.momDown": "%s收到 %s 則提問,比 %s的 %s 則少了 %.1f%%。",
"stats.insight.momFlat": "%s收到 %s 則提問,與 %s的 %s 則持平。",
"stats.insight.momUp": "%s收到 %s 則提問,比 %s的 %s 則多了 %.1f%%。",
"stats.insight.fastReply": "已回答的提問中,%s%% 在 24 小時內得到回覆。",
"stats.insight.private": "%s%% 的提問選擇了私密(不公開)。",
+50
View File
@@ -7,6 +7,7 @@ package stats
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
@@ -113,6 +114,55 @@ func TestOverviewDerived(t *testing.T) {
require.Contains(t, strings.Join(page.Insights, "\n"), "提问最集中在周一 8 点前后。")
}
// TestInsightMoMNamesBothMonths 环比句子必须点明是哪两个月、各多少条。
//
// 旧文案「上月提问量环比 -75.0%」要读者先把「上月」翻译成月份、再把「环比」
// 翻译成「比上上月」——两个相对词叠在一起,实测被读成「上月同期 vs 本月同期」。
// 这里锁住:两个月份名、两个条数、方向词与百分比同号。
func TestInsightMoMNamesBothMonths(t *testing.T) {
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
prev := monthStart.AddDate(0, -1, 0)
before := monthStart.AddDate(0, -2, 0)
cases := []struct {
name string
cur, prev int64
want string
}{
{"下降", 25, 100, "少了 75.0%"},
{"上升", 200, 100, "多了 100.0%"},
{"持平", 100, 100, "持平"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
raw := &db.SiteAnalytics{
TotalQuestions: 300,
Monthly: []db.CountPoint{
{Key: before.Format("2006-01"), N: c.prev},
{Key: prev.Format("2006-01"), N: c.cur},
},
}
joined := strings.Join(NewPage("zh-CN", buildOverview(raw, now)).Insights, "\n")
// 句子里得能读出「哪个月」「多少条」,否则又回到靠脑补的老样子。
require.Contains(t, joined, fmt.Sprintf("%d 月收到", int(prev.Month())))
require.Contains(t, joined, fmt.Sprintf("%d 月的 %s 条", int(before.Month()), numText(c.prev)))
require.Contains(t, joined, c.want)
})
}
}
// TestMonthText 月份名要按语言分别处理:英文不能用「9 月」,CJK 不用 "September"。
func TestMonthText(t *testing.T) {
require.Equal(t, "9 月", monthText("zh-CN", "2026-09"))
require.Equal(t, "9 月", monthText("zh-TW", "2026-09"))
require.Equal(t, "9 月", monthText("ja", "2026-09"))
require.Equal(t, "September", monthText("en", "2026-09"))
// 解析失败时原样返回(宁可露出原始 key,也别在句子里留空格)。
require.Equal(t, "bad-key", monthText("zh-CN", "bad-key"))
}
// fakeAnalytics 最小实现,用来卡住缓存的边界:
// 整页聚合一天只该算一次,但「今日」两项必须跟着每次请求走。
type fakeAnalytics struct {
+39 -2
View File
@@ -7,6 +7,8 @@ package stats
import (
"fmt"
"math"
"strings"
"time"
"github.com/tamakyi/TamaBox/internal/i18n"
)
@@ -119,7 +121,27 @@ func insights(lang string, ov *Overview) []string {
}
if ov.HasPrevMonth {
out = append(out, t("stats.insight.mom", fmt.Sprintf("%+.1f", ov.GrowthMoM)))
// 句子必须点明是哪两个月、各多少条。
//
// 原来写「上月提问量环比 -75.0%」:读者得自己先翻译「上月 = 9 月」、
// 再翻译「环比 = 比 8 月」,于是很容易读成「上月同期 vs 本月同期」。
// 两个相对词叠在一起,全靠脑补;把月份和条数写出来就没有歧义了。
n := len(ov.Monthly)
cur, prev := ov.Monthly[n-2], ov.Monthly[n-3]
pct := math.Abs(ov.GrowthMoM)
// pct 四舍五入后是 0.0 时按持平处理:别写「多了 0.0%」。
key := "stats.insight.momFlat"
if pct >= 0.05 {
if ov.GrowthMoM > 0 {
key = "stats.insight.momUp"
} else {
key = "stats.insight.momDown"
}
}
out = append(out, t(key,
monthText(lang, cur.Month), numText(cur.N),
monthText(lang, prev.Month), numText(prev.N),
pct))
}
if ov.Answered > 0 {
@@ -132,8 +154,23 @@ func insights(lang string, ov *Overview) []string {
return out
}
// numText 加千分位(2148 → "2,148")。
// monthText 把 "2026-09" 变成句子里能用的月份名。
//
// 中文 / 日文用「9 月」,英文要 "September"("month 9" 不是英语的说法)。
// 不带年份:要比较的两个月相邻,读者知道哪个月是「上个月」,年份只是噪音。
func monthText(lang, month string) string {
m, err := time.Parse("2006-01", month)
if err != nil {
// 解析不了就把原始 key 原样带出去,总比句子里空一格强。
return month
}
if strings.HasPrefix(lang, i18n.LangEn) {
return m.Month().String()
}
return fmt.Sprintf("%d 月", int(m.Month()))
}
// numText 加千分位(2148 → "2,148")。
// 模板侧有同名功能的 NumInt,但它在 internal/template 里;统计层反过来依赖渲染层
// 会让依赖方向变脏,所以这里重新写一份十行的实现。
func numText(v int64) string {