Files
tamakyi a3cf99a8da 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%」这种假证据,改成从数据算。
2026-10-25 09:27:57 +08:00

194 lines
6.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package stats
import (
"fmt"
"math"
"strings"
"time"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// Page 是 /stats 模板的渲染模型:在 Overview 之上补齐多语言标签、
// 图形需要的百分比,以及自动生成的文字结论。
//
// 为什么标签在这一层拼而不是模板里拼:桶标签是按序号取的(stats.len.3 之类),
// 模板里 printf 拼 key 一旦缺词条就会把 key 本身印到页面上;集中在这里拼,
// 缺词条时页面最多少一行文字,不会露出内部 key。
type Page struct {
*Overview
WeekdayLabels []string
LengthLabels []string
ReplyLabels []string
Insights []string
DailyValues []int64
MonthlyValues []int64
// HeatmapRows 按「周一 … 周日」重排后的热力图(Overview 里以周日为下标 0)。
HeatmapRows [7][24]int64
MonthlyPcts []float64
LengthPcts []float64
ReplyPcts []float64
}
// weekdayOrder 热力图与图例的展示顺序:从周一排到周日(数据库的 DOW 以周日为 0)。
var weekdayOrder = []int{1, 2, 3, 4, 5, 6, 0}
// NewPage 把聚合结果转成模板可直接渲染的模型。
func NewPage(lang string, ov *Overview) *Page {
p := &Page{Overview: ov}
for i := range weekdayOrder {
p.WeekdayLabels = append(p.WeekdayLabels, i18n.T(lang, fmt.Sprintf("stats.wd.%d", i+1)))
}
for i := 1; i <= len(ov.LengthBins); i++ {
p.LengthLabels = append(p.LengthLabels, i18n.T(lang, fmt.Sprintf("stats.len.%d", i)))
}
for i := 1; i <= len(ov.ReplyBins); i++ {
p.ReplyLabels = append(p.ReplyLabels, i18n.T(lang, fmt.Sprintf("stats.reply.%d", i)))
}
for _, d := range ov.Daily {
p.DailyValues = append(p.DailyValues, d.N)
}
for _, m := range ov.Monthly {
p.MonthlyValues = append(p.MonthlyValues, m.N)
}
for i, d := range weekdayOrder {
p.HeatmapRows[i] = ov.Heatmap[d]
}
p.MonthlyPcts = toPercents(p.MonthlyValues)
p.LengthPcts = toPercents(ov.LengthBins[:])
p.ReplyPcts = toPercents(ov.ReplyBins[:])
p.Insights = insights(lang, ov)
return p
}
// toPercents 把一组计数换算成相对最大值的百分比,供 CSS 直接当宽度 / 高度用。
func toPercents(values []int64) []float64 {
var max int64
for _, n := range values {
if n > max {
max = n
}
}
out := make([]float64, len(values))
if max == 0 {
return out
}
for i, n := range values {
out[i] = float64(n) / float64(max) * 100
}
return out
}
// insights 生成几句人话结论。
//
// 只陈述能从聚合数字直接读到的事实(最忙的一天、最集中的时段、环比、回复速度),
// 不做趋势预测、也不写「大家更活跃了」这类评价 —— 支撑那种判断的数据这张表里没有。
func insights(lang string, ov *Overview) []string {
t := func(key string, args ...interface{}) string { return i18n.T(lang, key, args...) }
out := make([]string, 0, 6)
if ov.TotalQuestions == 0 {
return append(out, t("stats.insight.empty"))
}
out = append(out, t("stats.insight.daily30",
numText(ov.Total30), math.Round(ov.AvgDailyLast30*10)/10))
if ov.PeakDay.N > 0 {
out = append(out, t("stats.insight.peak", ov.PeakDay.Label, numText(ov.PeakDay.N)))
}
if ov.HeatmapMax > 0 {
// DOW 以周日为 0,展示顺序以周一为 1,换算到 stats.weekday.N。
//
// 句子里必须用完整星期名(stats.weekday.*),不能用热力图列头那套
// 单字标签(stats.wd.* = "一"/"Mon"):列头要窄,句子里"三 19 点"
// 会被读成数字 3,英文那边则直接是 "Wed 19:00"。
wd := (ov.BusiestWD+6)%7 + 1
out = append(out, t("stats.insight.busiest",
i18n.T(lang, fmt.Sprintf("stats.weekday.%d", wd)), ov.BusiestHr))
}
if ov.HasPrevMonth {
// 句子必须点明是哪两个月、各多少条。
//
// 原来写「上月提问量环比 -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 {
out = append(out, t("stats.insight.fastReply", fmt.Sprintf("%.1f", ov.FastReplyRate)))
}
out = append(out, t("stats.insight.private",
fmt.Sprintf("%.1f", 100-float64(ov.Public)/float64(ov.TotalQuestions)*100)))
return out
}
// 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 {
s := fmt.Sprintf("%d", v)
if len(s) <= 3 {
return s
}
var b []byte
lead := len(s) % 3
if lead > 0 {
b = append(b, s[:lead]...)
}
for i := lead; i < len(s); i += 3 {
if len(b) > 0 {
b = append(b, ',')
}
b = append(b, s[i:i+3]...)
}
return string(b)
}