feat: added extra stats for contests

This commit is contained in:
2026-07-13 16:53:37 +02:00
parent 68982e9a85
commit ae60d58893
5 changed files with 313 additions and 32 deletions
+109 -22
View File
@@ -163,6 +163,21 @@ type Stats struct {
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote
Gaps []Gap `json:"gaps"` // the silences that make up OffAirMinutes, longest first
Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH")
// The contest RATE SHEET: hour by hour, who made the QSOs.
// RateOps are the operators, busiest first — that fixed order is also the
// colour/legend order, so an operator keeps their hue across the whole page.
// RateByOp[h][o] is operator o's count in hour h; rows align 1:1 with Rate, so
// the per-operator numbers always sum to the hour's total.
RateOps []string `json:"rate_ops"`
RateByOp [][]int `json:"rate_by_op"`
}
// entry is one dated QSO with the operator who made it — the pair the contest
// rate sheet needs. A bare timestamp can tell you HOW MANY, never BY WHOM.
type entry struct {
t time.Time
op string
}
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
@@ -219,7 +234,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
entityC = map[string]int{}
yearC = map[string]int{}
monthC = map[string]int{}
times []time.Time // every dated QSO, for the rate / gap maths
times []entry // every dated QSO (+ its operator), for the rate / gap maths
first, last time.Time
)
@@ -318,7 +333,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
}
yearC[t.Format("2006")]++
monthC[t.Format("2006-01")]++
times = append(times, t)
times = append(times, entry{t: t, op: op})
}
if err := rows.Err(); err != nil {
return s, err
@@ -404,6 +419,12 @@ func (s *Stats) ensureNonNil() {
if s.Gaps == nil {
s.Gaps = []Gap{}
}
if s.RateOps == nil {
s.RateOps = []string{}
}
if s.RateByOp == nil {
s.RateByOp = [][]int{}
}
}
// periodMetrics derives the rate / off-air figures that make a contest window
@@ -415,11 +436,11 @@ func (s *Stats) ensureNonNil() {
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// how fast you go when you ARE at the radio.
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time) {
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
if len(times) == 0 {
return
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
sort.Slice(times, func(i, j int) bool { return times[i].t.Before(times[j].t) })
winStart, winEnd := from, to
if winStart.IsZero() {
@@ -441,8 +462,8 @@ func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time
// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
hourly := map[string]int{}
for _, t := range times {
hourly[t.Format("2006-01-02 15")]++
for _, e := range times {
hourly[e.t.Format("2006-01-02 15")]++
}
for k, v := range hourly {
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
@@ -455,7 +476,7 @@ func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time
// actually quote. Two pointers over the sorted times: O(n).
lo := 0
for hi := range times {
for times[hi].Sub(times[lo]) >= time.Hour {
for times[hi].t.Sub(times[lo].t) >= time.Hour {
lo++
}
if n := hi - lo + 1; n > s.Best60 {
@@ -481,11 +502,11 @@ func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time
Minutes: int(d.Minutes()),
})
}
addGap(winStart, times[0]) // lead-in
for i := 1; i < len(times); i++ { // the silences between QSOs
addGap(times[i-1], times[i])
addGap(winStart, times[0].t) // lead-in
for i := 1; i < len(times); i++ { // the silences between QSOs
addGap(times[i-1].t, times[i].t)
}
addGap(times[len(times)-1], winEnd) // tail
addGap(times[len(times)-1].t, winEnd) // tail
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
if s.OnAirMinutes < 0 {
@@ -499,21 +520,87 @@ func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time
s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
}
// Per-hour rate timeline the classic contest rate chart. Every hour of the
// window, zeros included, so the silences are visible as silences.
if s.WindowHours <= rateMaxHours {
cur := winStart.Truncate(time.Hour)
end := winEnd.Truncate(time.Hour)
for !cur.After(end) {
s.Rate = append(s.Rate, Bucket{
Key: cur.Format("01-02 15"),
Count: hourly[cur.Format("2006-01-02 15")],
})
cur = cur.Add(time.Hour)
// Per-hour rate timeline + the RATE SHEET (who made those QSOs, hour by hour).
// Every hour of the window, zeros included, so the silences read as silences.
if s.WindowHours > rateMaxHours {
return
}
// Operators, busiest first. That order is fixed and reused as the colour/legend
// order, so an operator keeps the same hue everywhere on the page — a chart that
// repaints its series when the filter changes is a chart nobody can trust.
opTotals := map[string]int{}
for _, e := range times {
opTotals[e.op]++
}
s.RateOps = make([]string, 0, len(opTotals))
for op := range opTotals {
s.RateOps = append(s.RateOps, op)
}
sort.Slice(s.RateOps, func(i, j int) bool {
a, b := s.RateOps[i], s.RateOps[j]
if opTotals[a] != opTotals[b] {
return opTotals[a] > opTotals[b]
}
return a < b
})
// Never invent a 9th colour: past 8 operators the tail folds into "Other", which
// is honest and still sums correctly.
const maxOps = 8
folded := false
if len(s.RateOps) > maxOps {
s.RateOps = append(s.RateOps[:maxOps:maxOps], otherOp)
folded = true
}
opIdx := map[string]int{}
for i, op := range s.RateOps {
opIdx[op] = i
}
slotFor := func(op string) int {
if i, ok := opIdx[op]; ok {
return i
}
if folded {
return len(s.RateOps) - 1 // the "Other" bucket
}
return -1
}
// hourOps[hourKey][slot] — built from the same `times` as `hourly`, so the
// per-operator numbers ALWAYS sum to the hour's total. Deriving them separately
// is how a rate sheet ends up not adding up to its own total row.
hourOps := map[string][]int{}
for _, e := range times {
k := e.t.Format("2006-01-02 15")
row, ok := hourOps[k]
if !ok {
row = make([]int, len(s.RateOps))
hourOps[k] = row
}
if i := slotFor(e.op); i >= 0 {
row[i]++
}
}
cur := winStart.Truncate(time.Hour)
end := winEnd.Truncate(time.Hour)
for !cur.After(end) {
k := cur.Format("2006-01-02 15")
s.Rate = append(s.Rate, Bucket{Key: cur.Format("01-02 15"), Count: hourly[k]})
row := hourOps[k]
if row == nil {
row = make([]int, len(s.RateOps)) // a silent hour is zeros, not a missing row
}
s.RateByOp = append(s.RateByOp, row)
cur = cur.Add(time.Hour)
}
}
// otherOp is where operators past the 8th are folded. Generating a 9th colour is
// never the answer: under colour-blindness it is indistinguishable from one of the
// existing eight.
const otherOp = "Other"
// topBuckets sorts a count map most → least (ties alphabetical) and optionally
// keeps only the top n.
func topBuckets(m map[string]int, n int) []Bucket {