feat: stats — per-band mode split (CW / phone / data) stacked bar chart

Adds Stats.ByBandCategory (per band: CW/phone/data counts, band-plan order) via a
modeCategory() bucketing in stats.go, and a StackedBandBars chart in the Stats
panel showing the split per band with a legend.
This commit is contained in:
2026-07-20 21:20:29 +02:00
parent 3ec23bc613
commit 19c91f32a0
4 changed files with 120 additions and 6 deletions
+49 -2
View File
@@ -24,6 +24,30 @@ type Bucket struct {
Count int `json:"count"`
}
// BandCategory is one band's QSO count split by mode category (CW / phone /
// digital), for the per-band mode-split chart.
type BandCategory struct {
Band string `json:"band"`
CW int `json:"cw"`
Phone int `json:"phone"`
Data int `json:"data"`
Total int `json:"total"`
}
// modeCategory buckets a mode into "cw", "phone" or "data" (digital). Voice modes
// (SSB and the digital-voice family) count as phone; CW is CW; everything else is
// data. Mirrors the frontend's mode colouring.
func modeCategory(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
return "cw"
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "FREEDV", "C4FM", "DSTAR", "FUSION":
return "phone"
default:
return "data"
}
}
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
// are the expensive minutes: they are where the score went.
type Gap struct {
@@ -129,8 +153,9 @@ type Stats struct {
ConfirmedAny int `json:"confirmed_any"`
// Breakdowns, each sorted most → least (bands keep frequency order).
ByMode []Bucket `json:"by_mode"`
ByBand []Bucket `json:"by_band"`
ByMode []Bucket `json:"by_mode"`
ByBand []Bucket `json:"by_band"`
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
ByOperator []Bucket `json:"by_operator"`
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
ByContinent []Bucket `json:"by_continent"`
@@ -268,6 +293,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
entities = map[int]struct{}{}
modeC = map[string]int{}
bandC = map[string]int{}
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
opC = map[string]int{}
stationC = map[string]int{}
contC = map[string]int{}
@@ -338,6 +364,20 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
}
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
bandC[b]++
bc := bandCat[b]
if bc == nil {
bc = &BandCategory{Band: b}
bandCat[b] = bc
}
switch modeCategory(mode.String) {
case "cw":
bc.CW++
case "phone":
bc.Phone++
default:
bc.Data++
}
bc.Total++
}
// op was resolved above (with the operator filter applied).
opC[op]++
@@ -414,6 +454,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
}
return a < b
})
// Per-band CW/phone/data split, in the SAME band-plan order as ByBand.
s.ByBandCategory = []BandCategory{}
for _, b := range s.ByBand {
if bc := bandCat[b.Key]; bc != nil {
s.ByBandCategory = append(s.ByBandCategory, *bc)
}
}
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
// chart would invent activity that never happened. A gap in the log is real