feat: added extra stats for contests
This commit is contained in:
+109
-22
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user