From ae60d58893be7ca09bc1c2345d6e2f4af1992525 Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 13 Jul 2026 16:53:37 +0200 Subject: [PATCH] feat: added extra stats for contests --- frontend/src/components/StatsPanel.tsx | 153 ++++++++++++++++++++++++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/models.ts | 4 + internal/qso/stats.go | 131 +++++++++++++++++---- internal/qso/stats_test.go | 53 ++++++++- 5 files changed, 313 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/StatsPanel.tsx b/frontend/src/components/StatsPanel.tsx index 6040e46..b8a31f9 100644 --- a/frontend/src/components/StatsPanel.tsx +++ b/frontend/src/components/StatsPanel.tsx @@ -37,6 +37,7 @@ type Stats = { on_air_minutes: number; off_air_minutes: number; peak_hour_key: string; peak_hour_count: number; best_60: number; gaps: Gap[]; rate: Bucket[]; + rate_ops: string[]; rate_by_op: number[][]; }; // Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break). @@ -79,9 +80,13 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s // One series → one hue. The value is direct-labelled, so no reader ever depends // on a tooltip to get a number. -function HBars({ data, max, empty }: { data: Bucket[]; max?: number; empty: string }) { +function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) { + // max is a display cap for long tails (top entities). Where EVERY row matters — + // the operators of a multi-op — it is deliberately not set: a capped chart would + // silently drop the 9th operator, and "who worked what" is the whole question. const top = max ? data.slice(0, max) : data; const peak = Math.max(1, ...top.map((d) => d.count)); + const total = data.reduce((s, d) => s + d.count, 0); if (top.length === 0) return

{empty}

; return (
@@ -95,6 +100,11 @@ function HBars({ data, max, empty }: { data: Bucket[]; max?: number; empty: stri />
{nf(d.count)} + {share && ( + + {total ? ((d.count / total) * 100).toFixed(0) : 0}% + + )} ))} @@ -135,6 +145,123 @@ function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; h ); } +// ── Contest rate, stacked by operator ──────────────────────────────────────── +// The categories (the operators) ARE the subject, so this is categorical colour, +// in the FIXED order the backend sends (busiest first). An operator therefore +// keeps the same hue everywhere on the page — a chart that repaints its series +// when the filter changes is a chart nobody can trust. + +function RateStack({ rate, ops, byOp, empty, height = 130 }: { + rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number; +}) { + const [hov, setHov] = useState(null); + if (rate.length === 0) return

{empty}

; + const peak = Math.max(1, ...rate.map((d) => d.count)); + const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12); + const single = ops.length <= 1; // one operator → one hue; a legend would be noise + + return ( +
+
+ {rate.map((d, i) => ( +
setHov(i)} onMouseLeave={() => setHov(null)}> + {/* The column is the hour's total; the segments are who made it. */} +
+ {(byOp[i] ?? []).map((n, o) => n > 0 && ( +
+ ))} +
+ + {i % every === 0 ? d.key : ''} + +
+ ))} +
+ + {/* Hover read-out: the hour, its total, and the split. Values are also in the + rate sheet below, so nothing is reachable by hover alone. */} +

+ {hov !== null && ( + <> + {rate[hov].key} + {' · '}{nf(rate[hov].count)} QSO + {!single && (byOp[hov] ?? []).map((n, o) => n > 0 && ( + · {ops[o]} {n} + ))} + + )} +

+ + {!single && ( +
+ {ops.map((op, o) => ( + + + {op} + + ))} +
+ )} +
+ ); +} + +// ── The rate sheet: hour × operator, with the hour total ───────────────────── +// Exact numbers, many of them — that is a table's job, not a chart's. Contesters +// read rate sheets as tables, and every value here is also the one the chart draws. + +function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) { + if (rate.length === 0) return null; + const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here + const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0)); + const grand = rate.reduce((s, d) => s + d.count, 0); + + return ( +
+ + + + + {ops.map((op, o) => ( + + ))} + + + + + {shown.map(({ d, i }) => ( + + + {ops.map((_, o) => ( + + ))} + + + ))} + + + + + {totals.map((n, o) => )} + + + +
UTC + + {op} + Total
{d.key} + {byOp[i]?.[o] ? nf(byOp[i][o]) : ·} + {nf(d.count)}
Total{nf(n)}{nf(grand)}
+
+ ); +} + // ── Activity over time (single series → area, one hue) ──────────────────────── // Only the PEAK and the LAST point are direct-labelled. A number on every point // is chaos and goes unread. @@ -385,6 +512,7 @@ export function StatsPanel() { by_station: arr(raw.by_station), by_continent: arr(raw.by_continent), top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month), rate: arr(raw.rate), gaps: arr(raw.gaps), + rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op), } as Stats); } catch (e: any) { setErr(String(e?.message ?? e)); } @@ -521,7 +649,7 @@ export function StatsPanel() {
{stats.rate.length > 1 - ? + ? :

{t('stats.rateTooLong')}

} @@ -551,6 +679,16 @@ export function StatsPanel() { )} + + {/* The rate sheet: exact numbers, many of them, hour by hour and operator + by operator. That is a table's job, not a chart's — and it's how + contesters actually read a run. Every value here is also what the + stacked chart above draws, so the two can never disagree. */} + {stats.rate.length > 1 && ( + + + + )} )} @@ -577,8 +715,13 @@ export function StatsPanel() { + {/* EVERY operator, never a top-N: on a multi-op contest the point is who + worked what, and a cap would quietly delete the 9th operator. Scrolls + instead of truncating. */} - +
+ +
@@ -596,7 +739,9 @@ export function StatsPanel() { - +
+ +
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index ef031ed..bdf4e63 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -67,7 +67,7 @@ const en: Dict = { 'stats.best60': 'Best 60 min', 'stats.best60Tip': 'Best ROLLING 60 minutes (not the best clock hour) — the figure contesters quote.', 'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.', 'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total', - 'stats.noGaps': 'No break of 30 min or more.', 'stats.noContest': '— No contest —', + 'stats.noGaps': 'No break of 30 min or more.', 'stats.rateSheet': 'Rate sheet', 'stats.rateSheetSub': 'Hour by hour — who made the QSOs (silent hours omitted)', 'stats.noContest': '— No contest —', 'cluster.console': 'Console', 'cluster.clear': 'Clear', 'cluster.hideConsole': 'Hide console', 'cluster.showConsole': 'Show the raw cluster console', 'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).', 'msg.expand': 'Click to read the full message', @@ -310,7 +310,7 @@ const fr: Dict = { 'stats.best60': 'Meilleure heure', 'stats.best60Tip': 'Meilleurs 60 min GLISSANTES (pas la meilleure heure ronde) — le chiffre que citent les contesteurs.', 'stats.activeHours': 'Temps en l’air', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en l’air + hors antenne = la fenêtre, par construction.', 'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total', - 'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.noContest': '— Aucun contest —', + 'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.rateSheet': 'Feuille de cadence', 'stats.rateSheetSub': 'Heure par heure — qui a fait les QSO (heures muettes omises)', 'stats.noContest': '— Aucun contest —', 'cluster.console': 'Console', 'cluster.clear': 'Effacer', 'cluster.hideConsole': 'Masquer la console', 'cluster.showConsole': 'Afficher la console brute du cluster', 'cluster.consoleEmpty': 'Le trafic brut du cluster s’affiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).', 'msg.expand': 'Cliquer pour lire le message en entier', diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 54a9239..9f8c6e2 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -3367,6 +3367,8 @@ export namespace qso { best_60: number; gaps: Gap[]; rate: Bucket[]; + rate_ops: string[]; + rate_by_op: number[][]; static createFrom(source: any = {}) { return new Stats(source); @@ -3404,6 +3406,8 @@ export namespace qso { this.best_60 = source["best_60"]; this.gaps = this.convertValues(source["gaps"], Gap); this.rate = this.convertValues(source["rate"], Bucket); + this.rate_ops = source["rate_ops"]; + this.rate_by_op = source["rate_by_op"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/internal/qso/stats.go b/internal/qso/stats.go index 47e5ff4..4f9867e 100644 --- a/internal/qso/stats.go +++ b/internal/qso/stats.go @@ -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 { diff --git a/internal/qso/stats_test.go b/internal/qso/stats_test.go index bc99fb3..31205b0 100644 --- a/internal/qso/stats_test.go +++ b/internal/qso/stats_test.go @@ -57,7 +57,7 @@ func TestStatsNoNilSlices(t *testing.T) { // 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)} + times := []entry{{t: base, op: "A"}, {t: base.Add(2 * time.Minute), op: "A"}, {t: base.Add(5 * time.Minute), op: "B"}} var s2 Stats s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute)) s2.ensureNonNil() @@ -82,12 +82,12 @@ func TestContestPeriodMetrics(t *testing.T) { // 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 + var times []entry for i := 0; i < 10; i++ { - times = append(times, at(40+i*4)) // 12:40 … 13:16 + times = append(times, entry{t: at(40 + i*4), op: "F4BPO"}) // 12:40 … 13:16 } for i := 0; i < 3; i++ { - times = append(times, at(240+i*5)) // 16:00 … + times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 … } from := base // 12:00 @@ -139,6 +139,51 @@ func TestContestPeriodMetrics(t *testing.T) { } } +// The contest RATE SHEET: hour by hour, who made the QSOs. +// +// The invariant that matters: for EVERY hour, the per-operator numbers must sum to +// that hour's total. Derive the two separately and a rate sheet quietly stops +// adding up to its own total row — the sort of error nobody spots until someone +// checks the score by hand. +func TestRateSheetSumsToHourTotal(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) } + + times := []entry{ + {t: at(5), op: "F4BPO"}, {t: at(10), op: "F4BPO"}, {t: at(20), op: "F5XYZ"}, // hour 12: 3 + {t: at(70), op: "F5XYZ"}, {t: at(80), op: "F5XYZ"}, // hour 13: 2 + // hour 14 silent + {t: at(185), op: "F4BPO"}, // hour 15: 1 + } + var s Stats + s.periodMetrics(times, base, base.Add(4*time.Hour), time.Time{}, time.Time{}) + + if len(s.Rate) != len(s.RateByOp) { + t.Fatalf("rate rows (%d) and rate-sheet rows (%d) must align 1:1", len(s.Rate), len(s.RateByOp)) + } + // Both operators present, busiest first (they tie at 3 → alphabetical). + if len(s.RateOps) != 2 || s.RateOps[0] != "F4BPO" { + t.Fatalf("rate ops = %v, want [F4BPO F5XYZ]", s.RateOps) + } + for h := range s.Rate { + sum := 0 + for _, n := range s.RateByOp[h] { + sum += n + } + if sum != s.Rate[h].Count { + t.Errorf("hour %s: operators sum to %d but the hour total is %d — the rate sheet doesn't add up", + s.Rate[h].Key, sum, s.Rate[h].Count) + } + if len(s.RateByOp[h]) != len(s.RateOps) { + t.Errorf("hour %s: row has %d columns, want %d (one per operator)", s.Rate[h].Key, len(s.RateByOp[h]), len(s.RateOps)) + } + } + // The silent hour is a row of zeros, not a missing row. + if s.Rate[2].Count != 0 || s.RateByOp[2][0] != 0 || s.RateByOp[2][1] != 0 { + t.Errorf("the silent 14:00 hour must be zeros, got total=%d row=%v", s.Rate[2].Count, s.RateByOp[2]) + } +} + // 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.