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 (
+
+
+
+
+ | UTC |
+ {ops.map((op, o) => (
+
+
+ {op}
+ |
+ ))}
+ Total |
+
+
+
+ {shown.map(({ d, i }) => (
+
+ | {d.key} |
+ {ops.map((_, o) => (
+
+ {byOp[i]?.[o] ? nf(byOp[i][o]) : ·}
+ |
+ ))}
+ {nf(d.count)} |
+
+ ))}
+
+
+
+ | Total |
+ {totals.map((n, o) => {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.