feat: stats activity-over-time granularity selector (timeline / day / week / month / year)
Backend adds cyclical distributions ByHour (hour-of-day), ByDOW (weekday), ByDOM (day-of-month) and ByMonthYr (calendar month), all UTC. The Activity-over-time card gains a selector: Timeline keeps the chronological month trend; Day/Week/ Month/Year show the hour/weekday/day/month distribution as bars.
This commit is contained in:
@@ -163,6 +163,13 @@ type Stats struct {
|
||||
ByYear []Bucket `json:"by_year"` // chronological
|
||||
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
||||
|
||||
// Cyclical activity distributions (UTC), for the "activity over time" chart's
|
||||
// day/week/month/year granularity selector.
|
||||
ByHour []Bucket `json:"by_hour"` // hour of day 00..23
|
||||
ByDOW []Bucket `json:"by_dow"` // day of week Mon..Sun
|
||||
ByDOM []Bucket `json:"by_dom"` // day of month 1..31
|
||||
ByMonthYr []Bucket `json:"by_month_yr"` // month Jan..Dec
|
||||
|
||||
// ── Period / contest metrics ──
|
||||
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
||||
// nothing, but across a contest weekend it is the score. The window is the
|
||||
@@ -469,11 +476,43 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
s.ByMonth = fillMonths(monthC, first, last)
|
||||
|
||||
s.periodMetrics(times, from, to, first, last)
|
||||
s.timeDistributions(times)
|
||||
s.ensureNonNil()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// timeDistributions builds the cyclical activity buckets (hour-of-day, day-of-week,
|
||||
// day-of-month, month-of-year) from the dated QSOs, in UTC. Each is a fixed-length
|
||||
// series so the chart axis is stable even for empty slots.
|
||||
func (s *Stats) timeDistributions(times []entry) {
|
||||
hour := make([]int, 24)
|
||||
dow := make([]int, 7) // 0 = Monday
|
||||
dom := make([]int, 31)
|
||||
moy := make([]int, 12)
|
||||
for _, e := range times {
|
||||
t := e.t.UTC()
|
||||
hour[t.Hour()]++
|
||||
dow[(int(t.Weekday())+6)%7]++ // shift Sunday(0) → 6 so Monday is first
|
||||
if d := t.Day(); d >= 1 && d <= 31 {
|
||||
dom[d-1]++
|
||||
}
|
||||
moy[int(t.Month())-1]++
|
||||
}
|
||||
for h := 0; h < 24; h++ {
|
||||
s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02d", h), Count: hour[h]})
|
||||
}
|
||||
for i, n := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
|
||||
s.ByDOW = append(s.ByDOW, Bucket{Key: n, Count: dow[i]})
|
||||
}
|
||||
for d := 1; d <= 31; d++ {
|
||||
s.ByDOM = append(s.ByDOM, Bucket{Key: fmt.Sprintf("%d", d), Count: dom[d-1]})
|
||||
}
|
||||
for i, n := range []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} {
|
||||
s.ByMonthYr = append(s.ByMonthYr, Bucket{Key: n, Count: moy[i]})
|
||||
}
|
||||
}
|
||||
|
||||
// ensureNonNil replaces every nil slice with an empty one.
|
||||
//
|
||||
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
||||
|
||||
Reference in New Issue
Block a user