deat: Added statistics on your log

This commit is contained in:
2026-07-13 01:29:54 +02:00
parent b59c6856bd
commit eb9e2db41a
10 changed files with 1695 additions and 0 deletions
+580
View File
@@ -0,0 +1,580 @@
package qso
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
// Statistics over the whole logbook.
//
// Everything is aggregated IN GO from one lean scan rather than with SQL GROUP
// BYs. Two reasons: the date maths (year / month / hour of day) would need
// dialect-specific functions — strftime() on SQLite vs YEAR()/HOUR() on MySQL —
// which is exactly the kind of thing that silently works on one backend and
// breaks on the other; and a single pass over a few columns of a 30k-row log is
// a few tens of milliseconds, so the complexity buys nothing.
// Bucket is one labelled count (mode, band, operator, entity…).
type Bucket struct {
Key string `json:"key"`
Count int `json:"count"`
}
// 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 {
Start string `json:"start"` // RFC3339 — the last QSO before the silence
End string `json:"end"` // the first QSO after it
Minutes int `json:"minutes"`
}
// ContestRun is one contest the operator actually took part in, discovered FROM
// THE LOG (a CONTEST_ID plus the year it ran) rather than from a static list — so
// the picker only ever offers contests you really entered, and never an empty one.
type ContestRun struct {
ID string `json:"id"`
Year int `json:"year"`
Count int `json:"count"`
Start string `json:"start"` // first QSO, RFC3339
End string `json:"end"` // last QSO
}
// ContestRuns lists every (contest, year) pair present in the logbook, most
// recent first.
func (r *Repo) ContestRuns(ctx context.Context) ([]ContestRun, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT contest_id, qso_date FROM qso WHERE contest_id IS NOT NULL AND contest_id <> ''`)
if err != nil {
return nil, err
}
defer rows.Close()
type key struct {
id string
year int
}
agg := map[key]*ContestRun{}
for rows.Next() {
var id, dateStr sql.NullString
if err := rows.Scan(&id, &dateStr); err != nil {
return nil, err
}
cid := strings.ToUpper(strings.TrimSpace(id.String))
if cid == "" {
continue
}
t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() {
continue
}
k := key{cid, t.Year()}
c, ok := agg[k]
if !ok {
c = &ContestRun{ID: cid, Year: t.Year(), Start: t.Format(time.RFC3339), End: t.Format(time.RFC3339)}
agg[k] = c
}
c.Count++
if t.Format(time.RFC3339) < c.Start {
c.Start = t.Format(time.RFC3339)
}
if t.Format(time.RFC3339) > c.End {
c.End = t.Format(time.RFC3339)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]ContestRun, 0, len(agg))
for _, c := range agg {
out = append(out, *c)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Year != out[j].Year {
return out[i].Year > out[j].Year // most recent first
}
return out[i].ID < out[j].ID
})
return out, nil
}
// gapThreshold is the silence that counts as "off the air". Short enough to catch
// a real break, long enough not to flag the normal pause between two QSOs.
const gapThreshold = 30 * time.Minute
// rateMaxHours caps the per-hour rate timeline. A contest weekend is ~48 h, so a
// week is generous. This is a READABILITY limit, not a memory one: at 30 days the
// chart is 720 hourly bars, each about a pixel wide with an unreadable label — it
// looks broken, which is exactly how it first shipped. Past this the UI says
// "period too long for an hourly chart" instead of drawing mush.
const rateMaxHours = 7 * 24
// Stats is the whole dashboard payload.
type Stats struct {
// Headline figures.
Total int `json:"total"`
UniqueCalls int `json:"unique_calls"`
Entities int `json:"entities"` // distinct DXCC entities
Continents int `json:"continents"` // distinct continents
FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty
LastQSO string `json:"last_qso"`
// Confirmations (of Total).
ConfirmedLoTW int `json:"confirmed_lotw"`
ConfirmedEQSL int `json:"confirmed_eqsl"`
ConfirmedQSL int `json:"confirmed_qsl"`
ConfirmedAny int `json:"confirmed_any"`
// Breakdowns, each sorted most → least (bands keep frequency order).
ByMode []Bucket `json:"by_mode"`
ByBand []Bucket `json:"by_band"`
ByOperator []Bucket `json:"by_operator"`
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
ByContinent []Bucket `json:"by_continent"`
TopEntities []Bucket `json:"top_entities"`
ByYear []Bucket `json:"by_year"` // chronological
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
// ── 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
// requested [from,to] when given, else the span of the log.
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
WindowHours float64 `json:"window_hours"`
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
// On-air and off-air are a TIME BUDGET and must add up to the window:
// OnAirMinutes + OffAirMinutes == window
// The first version counted "clock hours containing at least one QSO" as on-air,
// so a single QSO at 08:05 booked the whole 08:00 hour. On a 45 h contest that
// gave 39 h on air AND 16 h 43 off air — 56 h inside a 45 h window. Two numbers
// measured on incompatible bases can't be compared, and the operator rightly
// didn't believe either of them.
OnAirMinutes int `json:"on_air_minutes"`
OffAirMinutes int `json:"off_air_minutes"`
PeakHourKey string `json:"peak_hour_key"` // best clock hour (kept for reference)
PeakHourCount int `json:"peak_hour_count"`
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")
}
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
// so the band chart reads like a band plan instead of a jumble.
var bandOrder = map[string]int{
"2190m": 1, "630m": 2, "160m": 3, "80m": 4, "60m": 5, "40m": 6, "30m": 7,
"20m": 8, "17m": 9, "15m": 10, "12m": 11, "10m": 12, "6m": 13, "4m": 14,
"2m": 15, "1.25m": 16, "70cm": 17, "23cm": 18, "13cm": 19,
}
// yes reports whether an ADIF confirmation flag means "confirmed".
func yes(s string) bool {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "Y", "V": // V = verified (LoTW)
return true
}
return false
}
// Stats scans the logbook once and returns every breakdown the dashboard needs,
// restricted to [from, to] (a zero time means "no bound", so a zero/zero pair is
// the whole log).
//
// The window is applied HERE, in Go, on the parsed timestamp — not as a SQL
// WHERE. qso_date is a text column whose format differs between the two backends,
// so a string comparison would quietly select the wrong rows on one of them. We
// already parse every date in this pass; filtering on the parsed value is both
// correct and free.
// contestID (with an optional year, 0 = any) narrows the log to one contest. When
// it is set and no explicit window is given, the window becomes the contest's own
// span — so rate, best-hour and off-air figures are computed over the contest
// itself without the operator having to look its dates up.
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) {
var s Stats
contestID = strings.ToUpper(strings.TrimSpace(contestID))
rows, err := r.db.QueryContext(ctx, `
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
operator, station_callsign, lotw_rcvd, eqsl_rcvd, qsl_rcvd, contest_id
FROM qso`)
if err != nil {
return s, err
}
defer rows.Close()
var (
calls = map[string]struct{}{}
entities = map[int]struct{}{}
modeC = map[string]int{}
bandC = map[string]int{}
opC = map[string]int{}
stationC = map[string]int{}
contC = map[string]int{}
entityC = map[string]int{}
yearC = map[string]int{}
monthC = map[string]int{}
times []time.Time // every dated QSO, for the rate / gap maths
first, last time.Time
)
for rows.Next() {
var (
call, band, mode, cont, country sql.NullString
oper, station sql.NullString
lotw, eqsl, paper sql.NullString
dxcc sql.NullInt64
dateStr, contestID2 sql.NullString
)
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
return s, err
}
// Contest filter first — same reasoning as the window below: a QSO that
// isn't in this contest must not reach ANY bucket.
if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
continue
}
// Window first: a QSO outside the period must not reach ANY bucket. Doing
// this after the counting (the obvious mistake) would leave the mode/band/
// operator charts showing the whole log while only the trend was filtered.
// parseTimeLoose is the repo's existing convention for qso_date — it copes
// with what each backend hands back (SQLite ISO string, MySQL DATETIME).
t := parseTimeLoose(dateStr.String).UTC()
dated := !t.IsZero()
if year > 0 && (!dated || t.Year() != year) {
continue
}
if !from.IsZero() && (!dated || t.Before(from)) {
continue
}
if !to.IsZero() && (!dated || t.After(to)) {
continue
}
s.Total++
if c := strings.ToUpper(strings.TrimSpace(call.String)); c != "" {
calls[c] = struct{}{}
}
if dxcc.Valid && dxcc.Int64 > 0 {
entities[int(dxcc.Int64)] = struct{}{}
}
if m := strings.ToUpper(strings.TrimSpace(mode.String)); m != "" {
modeC[m]++
}
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
bandC[b]++
}
// An empty OPERATOR means "the station owner logged it himself" — bucket
// it explicitly rather than dropping the QSO from the operator chart.
op := strings.ToUpper(strings.TrimSpace(oper.String))
if op == "" {
op = "—"
}
opC[op]++
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
stationC[st]++
}
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
contC[c]++
}
if c := strings.TrimSpace(country.String); c != "" {
entityC[c]++
}
cl, el, pl := yes(lotw.String), yes(eqsl.String), yes(paper.String)
if cl {
s.ConfirmedLoTW++
}
if el {
s.ConfirmedEQSL++
}
if pl {
s.ConfirmedQSL++
}
if cl || el || pl {
s.ConfirmedAny++
}
// An undated QSO still counts in the mode/band/operator totals above, but
// it can't be placed on a time axis — leave it out of the trend rather than
// parking it at year zero.
if !dated {
continue
}
if first.IsZero() || t.Before(first) {
first = t
}
if last.IsZero() || t.After(last) {
last = t
}
yearC[t.Format("2006")]++
monthC[t.Format("2006-01")]++
times = append(times, t)
}
if err := rows.Err(); err != nil {
return s, err
}
s.UniqueCalls = len(calls)
s.Entities = len(entities)
s.Continents = len(contC)
if !first.IsZero() {
s.FirstQSO = first.UTC().Format(time.RFC3339)
s.LastQSO = last.UTC().Format(time.RFC3339)
}
s.ByMode = topBuckets(modeC, 0)
s.ByOperator = topBuckets(opC, 0)
s.ByStation = topBuckets(stationC, 0)
s.ByContinent = topBuckets(contC, 0)
s.TopEntities = topBuckets(entityC, 15)
// Bands read in band-plan order, not by count — the shape of the chart IS
// the band plan, and re-sorting it by size would destroy that.
s.ByBand = sortedBuckets(bandC, func(a, b string) bool {
oa, ob := bandOrder[a], bandOrder[b]
if oa == 0 {
oa = 99
}
if ob == 0 {
ob = 99
}
if oa != ob {
return oa < ob
}
return a < b
})
// 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
// information: it belongs on the chart as zeros.
s.ByYear = fillYears(yearC, first, last)
s.ByMonth = fillMonths(monthC, first, last)
s.periodMetrics(times, from, to, first, last)
s.ensureNonNil()
return s, nil
}
// ensureNonNil replaces every nil slice with an empty one.
//
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
// UI then calls .length / .map on null — a TypeError that unmounts the whole React
// tree and leaves a WHITE SCREEN. It bites exactly in the innocent cases: a contest
// with no break ≥ 30 min (Gaps nil), or a window too long for the hourly chart
// (Rate nil). The awards code carries the same guard for the same reason.
func (s *Stats) ensureNonNil() {
if s.ByMode == nil {
s.ByMode = []Bucket{}
}
if s.ByBand == nil {
s.ByBand = []Bucket{}
}
if s.ByOperator == nil {
s.ByOperator = []Bucket{}
}
if s.ByStation == nil {
s.ByStation = []Bucket{}
}
if s.ByContinent == nil {
s.ByContinent = []Bucket{}
}
if s.TopEntities == nil {
s.TopEntities = []Bucket{}
}
if s.ByYear == nil {
s.ByYear = []Bucket{}
}
if s.ByMonth == nil {
s.ByMonth = []Bucket{}
}
if s.Rate == nil {
s.Rate = []Bucket{}
}
if s.Gaps == nil {
s.Gaps = []Gap{}
}
}
// periodMetrics derives the rate / off-air figures that make a contest window
// readable. The window is the caller's [from,to] when given, else the span of the
// log itself.
//
// Two rates are reported on purpose, because a single one always flatters:
// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
// • 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) {
if len(times) == 0 {
return
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
winStart, winEnd := from, to
if winStart.IsZero() {
winStart = first
}
if winEnd.IsZero() {
winEnd = last
}
if !winEnd.After(winStart) {
return
}
s.WindowStart = winStart.Format(time.RFC3339)
s.WindowEnd = winEnd.Format(time.RFC3339)
s.WindowHours = winEnd.Sub(winStart).Hours()
if s.WindowHours > 0 {
s.AvgPerHour = float64(len(times)) / s.WindowHours
}
// 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 k, v := range hourly {
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
s.PeakHourKey, s.PeakHourCount = k, v
}
}
// Best ROLLING 60 minutes — not the best clock hour. A run straddling 13:45
// 14:45 is invisible to clock-hour bucketing, and it's the figure contesters
// actually quote. Two pointers over the sorted times: O(n).
lo := 0
for hi := range times {
for times[hi].Sub(times[lo]) >= time.Hour {
lo++
}
if n := hi - lo + 1; n > s.Best60 {
s.Best60 = n
}
}
// Off-air is a TIME BUDGET, and it has to close on the window:
// OnAirMinutes + OffAirMinutes == window
// So every silence ≥ 30 min counts — including the lead-in before the first QSO
// and the tail after the last, when an explicit window was asked for. Skipping
// those (the first version did) makes "on air" and "off air" sum to more than
// the window, and then neither number is believable.
addGap := func(a, b time.Time) {
d := b.Sub(a)
if d < gapThreshold {
return
}
s.OffAirMinutes += int(d.Minutes())
s.Gaps = append(s.Gaps, Gap{
Start: a.Format(time.RFC3339),
End: b.Format(time.RFC3339),
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(times[len(times)-1], winEnd) // tail
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
if s.OnAirMinutes < 0 {
s.OnAirMinutes = 0
}
if s.OnAirMinutes > 0 {
s.AvgPerActive = float64(len(times)) / (float64(s.OnAirMinutes) / 60)
}
sort.Slice(s.Gaps, func(i, j int) bool { return s.Gaps[i].Minutes > s.Gaps[j].Minutes })
if len(s.Gaps) > 10 {
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)
}
}
}
// 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 {
out := make([]Bucket, 0, len(m))
for k, v := range m {
out = append(out, Bucket{Key: k, Count: v})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Key < out[j].Key
})
if n > 0 && len(out) > n {
out = out[:n]
}
return out
}
// sortedBuckets keeps a caller-defined key order (band plan, chronology).
func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) })
out := make([]Bucket, 0, len(keys))
for _, k := range keys {
out = append(out, Bucket{Key: k, Count: m[k]})
}
return out
}
// fillMonths emits EVERY month between the first and last QSO — zeros included —
// so the trend line's x-axis is real time rather than "months that happen to have
// data". A quiet decade must read as a decade at zero, not vanish.
func fillMonths(m map[string]int, first, last time.Time) []Bucket {
if first.IsZero() {
return nil
}
var out []Bucket
cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC)
end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC)
for !cur.After(end) {
k := cur.Format("2006-01")
out = append(out, Bucket{Key: k, Count: m[k]})
cur = cur.AddDate(0, 1, 0)
}
return out
}
// fillYears does the same for the yearly view.
func fillYears(m map[string]int, first, last time.Time) []Bucket {
if first.IsZero() {
return nil
}
var out []Bucket
for y := first.Year(); y <= last.Year(); y++ {
k := fmt.Sprintf("%04d", y)
out = append(out, Bucket{Key: k, Count: m[k]})
}
return out
}
+183
View File
@@ -0,0 +1,183 @@
package qso
import (
"testing"
"time"
)
// Bands must read in BAND-PLAN order (160m → 70cm), never by count and never
// alphabetically — the order of that chart IS the information.
func TestBandPlanOrder(t *testing.T) {
counts := map[string]int{"20m": 9312, "160m": 77, "70cm": 3, "40m": 5196, "10m": 3401, "80m": 2332}
got := sortedBuckets(counts, func(a, b string) bool {
oa, ob := bandOrder[a], bandOrder[b]
if oa == 0 {
oa = 99
}
if ob == 0 {
ob = 99
}
if oa != ob {
return oa < ob
}
return a < b
})
want := []string{"160m", "80m", "40m", "20m", "10m", "70cm"}
if len(got) != len(want) {
t.Fatalf("got %d buckets, want %d", len(got), len(want))
}
for i := range want {
if got[i].Key != want[i] {
t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Key, want[i], got)
}
}
}
// A nil Go slice marshals to JSON `null`, not `[]` — and the UI then calls
// .length/.map on null, which unmounts the whole React tree and leaves a WHITE
// SCREEN. It bites in the innocent cases: a contest with no break ≥ 30 min (Gaps
// nil), or a window too long for the hourly chart (Rate nil). Every slice the
// dashboard reads must therefore come back non-nil, even when empty.
func TestStatsNoNilSlices(t *testing.T) {
var s Stats // the worst case: nothing computed at all
s.ensureNonNil()
checks := map[string]bool{
"ByMode": s.ByMode == nil, "ByBand": s.ByBand == nil, "ByOperator": s.ByOperator == nil,
"ByStation": s.ByStation == nil, "ByContinent": s.ByContinent == nil,
"TopEntities": s.TopEntities == nil, "ByYear": s.ByYear == nil, "ByMonth": s.ByMonth == nil,
"Rate": s.Rate == nil, "Gaps": s.Gaps == nil,
}
for name, isNil := range checks {
if isNil {
t.Errorf("%s is nil → marshals to JSON null → white screen in the UI", name)
}
}
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
// window that yields no hourly chart must still produce [] and not null.
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
times := []time.Time{base, base.Add(2 * time.Minute), base.Add(5 * time.Minute)}
var s2 Stats
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
s2.ensureNonNil()
if s2.Gaps == nil {
t.Error("Gaps nil for a gap-free run — this is exactly the contest that white-screened")
}
if len(s2.Gaps) != 0 {
t.Errorf("Gaps = %v, want empty (no silence ≥ 30 min in this run)", s2.Gaps)
}
}
// Contest metrics over a window. The two traps:
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
// a run straddling 13:4514:45 is invisible to clock-hour bucketing, and the
// rolling figure is the one contesters quote.
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
// 8-hour effort gets sold as a 48-hour score.
func TestContestPeriodMetrics(t *testing.T) {
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
// then a 2-hour silence, then 3 more.
var times []time.Time
for i := 0; i < 10; i++ {
times = append(times, at(40+i*4)) // 12:40 … 13:16
}
for i := 0; i < 3; i++ {
times = append(times, at(240+i*5)) // 16:00 …
}
from := base // 12:00
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
var s Stats
s.periodMetrics(times, from, to, time.Time{}, time.Time{})
if s.WindowHours != 6 {
t.Errorf("window = %.1f h, want 6", s.WindowHours)
}
// 13 QSOs over a 6 h window.
if got := s.AvgPerHour; got < 2.16 || got > 2.17 {
t.Errorf("avg/h over the window = %.3f, want ~2.167 (13÷6)", got)
}
// The rolling hour must find the straddling run of 10 — a clock-hour bucket
// would only ever see part of it.
if s.Best60 != 10 {
t.Errorf("best rolling 60 min = %d, want 10 (the 12:40→13:16 run)", s.Best60)
}
if s.PeakHourCount >= 10 {
t.Errorf("peak CLOCK hour = %d — it should be < 10, which is exactly why the rolling figure exists", s.PeakHourCount)
}
// THE INVARIANT: on-air + off-air must close on the window. The first version
// counted "clock hours containing a QSO" as on-air, which on a real 45 h contest
// reported 39 h on air AND 16 h 43 off air — 56 h inside 45 h. Two numbers on
// incompatible bases; the operator believed neither, and was right.
if got := s.OnAirMinutes + s.OffAirMinutes; got != int(s.WindowHours*60) {
t.Errorf("on-air (%d) + off-air (%d) = %d min, but the window is %d min — the budget must close",
s.OnAirMinutes, s.OffAirMinutes, got, int(s.WindowHours*60))
}
// Off air = lead-in (12:00→12:40 = 40 min) + the 13:16→16:00 silence (164) +
// the tail (16:10→18:00 = 110). Silences ≥ 30 min all count, wherever they sit:
// ignoring the lead-in and tail is what broke the budget.
if s.OffAirMinutes != 40+164+110 {
t.Errorf("off-air = %d min, want %d (lead-in + gap + tail)", s.OffAirMinutes, 40+164+110)
}
if len(s.Gaps) != 3 {
t.Fatalf("gaps = %+v, want 3 (lead-in, the silence, the tail)", s.Gaps)
}
if s.AvgPerActive <= s.AvgPerHour {
t.Errorf("avg/on-air (%.2f) must exceed avg/window (%.2f) when there are breaks", s.AvgPerActive, s.AvgPerHour)
}
// The rate timeline covers EVERY hour of the window, silences as zeros.
if len(s.Rate) != 7 { // 12,13,14,15,16,17,18
t.Fatalf("rate timeline = %d hours, want 7 (every hour of the window)", len(s.Rate))
}
if s.Rate[2].Count != 0 || s.Rate[3].Count != 0 {
t.Errorf("the 14:00/15:00 silence must show as zeros, got %+v %+v", s.Rate[2], s.Rate[3])
}
}
// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
// would invent activity that never happened.
func TestTimeAxisIsContinuous(t *testing.T) {
first := time.Date(2009, 5, 30, 0, 0, 0, 0, time.UTC)
last := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
years := fillYears(map[string]int{"2009": 79, "2012": 1187, "2026": 14415}, first, last)
if len(years) != 18 { // 2009..2026 inclusive
t.Fatalf("years = %d, want 18 (2009→2026 with no holes)", len(years))
}
byKey := map[string]int{}
for _, b := range years {
byKey[b.Key] = b.Count
}
if byKey["2010"] != 0 || byKey["2018"] != 0 {
t.Errorf("silent years must be present as zero, got 2010=%d 2018=%d", byKey["2010"], byKey["2018"])
}
if byKey["2012"] != 1187 || byKey["2026"] != 14415 {
t.Errorf("real counts lost: 2012=%d 2026=%d", byKey["2012"], byKey["2026"])
}
months := fillMonths(map[string]int{"2009-05": 49, "2026-07": 1}, first, last)
// May 2009 → July 2026 inclusive = 17 years * 12 + 3 = 207 months.
if len(months) != 207 {
t.Fatalf("months = %d, want 207 (continuous)", len(months))
}
if months[0].Key != "2009-05" || months[0].Count != 49 {
t.Errorf("first month = %+v, want 2009-05 / 49", months[0])
}
if months[len(months)-1].Key != "2026-07" {
t.Errorf("last month = %q, want 2026-07", months[len(months)-1].Key)
}
// Every step is exactly one month — no jumps.
for i := 1; i < len(months); i++ {
prev, _ := time.Parse("2006-01", months[i-1].Key)
cur, _ := time.Parse("2006-01", months[i].Key)
if !prev.AddDate(0, 1, 0).Equal(cur) {
t.Fatalf("gap in the time axis between %q and %q", months[i-1].Key, months[i].Key)
}
}
}