// 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) }