feat: added extra stats for contests

This commit is contained in:
2026-07-13 16:53:37 +02:00
parent 68982e9a85
commit ae60d58893
5 changed files with 313 additions and 32 deletions
+149 -4
View File
@@ -37,6 +37,7 @@ type Stats = {
on_air_minutes: number; off_air_minutes: number; on_air_minutes: number; off_air_minutes: number;
peak_hour_key: string; peak_hour_count: number; best_60: number; peak_hour_key: string; peak_hour_count: number; best_60: number;
gaps: Gap[]; rate: Bucket[]; 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). // 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 // One series → one hue. The value is direct-labelled, so no reader ever depends
// on a tooltip to get a number. // 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 top = max ? data.slice(0, max) : data;
const peak = Math.max(1, ...top.map((d) => d.count)); 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 <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>; if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
return ( return (
<div className="flex flex-col gap-1.5 min-w-0"> <div className="flex flex-col gap-1.5 min-w-0">
@@ -95,6 +100,11 @@ function HBars({ data, max, empty }: { data: Bucket[]; max?: number; empty: stri
/> />
</div> </div>
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span> <span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
{share && (
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
</span>
)}
</div> </div>
))} ))}
</div> </div>
@@ -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<number | null>(null);
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
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 (
<div className="min-w-0">
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
{rate.map((d, i) => (
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
{/* The column is the hour's total; the segments are who made it. */}
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
{(byOp[i] ?? []).map((n, o) => n > 0 && (
<div key={o} style={{
height: `${(n / Math.max(1, d.count)) * 100}%`,
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
}} />
))}
</div>
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
{i % every === 0 ? d.key : ''}
</span>
</div>
))}
</div>
{/* 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. */}
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
{hov !== null && (
<>
<span className="text-foreground font-medium">{rate[hov].key}</span>
{' · '}{nf(rate[hov].count)} QSO
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}></span> {ops[o]} {n}</span>
))}
</>
)}
</p>
{!single && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
{ops.map((op, o) => (
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
{op}
</span>
))}
</div>
)}
</div>
);
}
// ── 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 (
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
<table className="w-full text-[11px] tabular-nums">
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
<tr className="text-muted-foreground">
<th className="text-left font-medium px-2 py-1">UTC</th>
{ops.map((op, o) => (
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
{op}
</th>
))}
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
</tr>
</thead>
<tbody>
{shown.map(({ d, i }) => (
<tr key={d.key} className="border-t border-border/50">
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
{ops.map((_, o) => (
<td key={o} className="px-2 py-0.5 text-right">
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
</td>
))}
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
</tr>
))}
</tbody>
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
<tr className="border-t border-border font-semibold">
<td className="px-2 py-1">Total</td>
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
<td className="px-2 py-1 text-right">{nf(grand)}</td>
</tr>
</tfoot>
</table>
</div>
);
}
// ── Activity over time (single series → area, one hue) ──────────────────────── // ── Activity over time (single series → area, one hue) ────────────────────────
// Only the PEAK and the LAST point are direct-labelled. A number on every point // Only the PEAK and the LAST point are direct-labelled. A number on every point
// is chaos and goes unread. // is chaos and goes unread.
@@ -385,6 +512,7 @@ export function StatsPanel() {
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent), 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), 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: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats); } as Stats);
} }
catch (e: any) { setErr(String(e?.message ?? e)); } catch (e: any) { setErr(String(e?.message ?? e)); }
@@ -521,7 +649,7 @@ export function StatsPanel() {
</div> </div>
</div> </div>
{stats.rate.length > 1 {stats.rate.length > 1
? <VBars data={stats.rate} empty={empty} height={120} /> ? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>} : <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
</Card> </Card>
@@ -551,6 +679,16 @@ export function StatsPanel() {
</ul> </ul>
)} )}
</Card> </Card>
{/* 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 && (
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
</Card>
)}
</div> </div>
)} )}
@@ -577,8 +715,13 @@ export function StatsPanel() {
<AreaTrend data={stats.by_month} empty={empty} /> <AreaTrend data={stats.by_month} empty={empty} />
</Card> </Card>
{/* 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. */}
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}> <Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
<HBars data={stats.by_operator} max={8} empty={empty} /> <div className="max-h-[240px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_operator} empty={empty} share />
</div>
</Card> </Card>
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}> <Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
<Donut data={stats.by_continent} empty={empty} /> <Donut data={stats.by_continent} empty={empty} />
@@ -596,7 +739,9 @@ export function StatsPanel() {
</div> </div>
</Card> </Card>
<Card title={t('stats.byStation')}> <Card title={t('stats.byStation')}>
<HBars data={stats.by_station} max={4} empty={empty} /> <div className="max-h-[140px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_station} empty={empty} share />
</div>
</Card> </Card>
</div> </div>
+2 -2
View File
@@ -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.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.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.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.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, …).', 'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
'msg.expand': 'Click to read the full message', '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.best60': 'Meilleure heure', 'stats.best60Tip': 'Meilleurs 60 min GLISSANTES (pas la meilleure heure ronde) — le chiffre que citent les contesteurs.',
'stats.activeHours': 'Temps en lair', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en lair + hors antenne = la fenêtre, par construction.', 'stats.activeHours': 'Temps en lair', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en lair + hors antenne = la fenêtre, par construction.',
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total', '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.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 saffiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).', 'cluster.consoleEmpty': 'Le trafic brut du cluster saffiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
'msg.expand': 'Cliquer pour lire le message en entier', 'msg.expand': 'Cliquer pour lire le message en entier',
+4
View File
@@ -3367,6 +3367,8 @@ export namespace qso {
best_60: number; best_60: number;
gaps: Gap[]; gaps: Gap[];
rate: Bucket[]; rate: Bucket[];
rate_ops: string[];
rate_by_op: number[][];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Stats(source); return new Stats(source);
@@ -3404,6 +3406,8 @@ export namespace qso {
this.best_60 = source["best_60"]; this.best_60 = source["best_60"];
this.gaps = this.convertValues(source["gaps"], Gap); this.gaps = this.convertValues(source["gaps"], Gap);
this.rate = this.convertValues(source["rate"], Bucket); 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 { convertValues(a: any, classs: any, asMap: boolean = false): any {
+105 -18
View File
@@ -163,6 +163,21 @@ type Stats struct {
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote 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 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") 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, // 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{} entityC = map[string]int{}
yearC = map[string]int{} yearC = map[string]int{}
monthC = 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 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")]++ yearC[t.Format("2006")]++
monthC[t.Format("2006-01")]++ monthC[t.Format("2006-01")]++
times = append(times, t) times = append(times, entry{t: t, op: op})
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return s, err return s, err
@@ -404,6 +419,12 @@ func (s *Stats) ensureNonNil() {
if s.Gaps == nil { if s.Gaps == nil {
s.Gaps = []Gap{} 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 // 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 // • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// how fast you go when you ARE at the radio. // 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. // 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 { if len(times) == 0 {
return 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 winStart, winEnd := from, to
if winStart.IsZero() { 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 // 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. // "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
hourly := map[string]int{} hourly := map[string]int{}
for _, t := range times { for _, e := range times {
hourly[t.Format("2006-01-02 15")]++ hourly[e.t.Format("2006-01-02 15")]++
} }
for k, v := range hourly { for k, v := range hourly {
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) { 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). // actually quote. Two pointers over the sorted times: O(n).
lo := 0 lo := 0
for hi := range times { for hi := range times {
for times[hi].Sub(times[lo]) >= time.Hour { for times[hi].t.Sub(times[lo].t) >= time.Hour {
lo++ lo++
} }
if n := hi - lo + 1; n > s.Best60 { 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()), Minutes: int(d.Minutes()),
}) })
} }
addGap(winStart, times[0]) // lead-in addGap(winStart, times[0].t) // lead-in
for i := 1; i < len(times); i++ { // the silences between QSOs for i := 1; i < len(times); i++ { // the silences between QSOs
addGap(times[i-1], times[i]) 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 s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
if s.OnAirMinutes < 0 { if s.OnAirMinutes < 0 {
@@ -499,20 +520,86 @@ 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 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 // Per-hour rate timeline + the RATE SHEET (who made those QSOs, hour by hour).
// window, zeros included, so the silences are visible as silences. // Every hour of the window, zeros included, so the silences read as silences.
if s.WindowHours <= rateMaxHours { 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) cur := winStart.Truncate(time.Hour)
end := winEnd.Truncate(time.Hour) end := winEnd.Truncate(time.Hour)
for !cur.After(end) { for !cur.After(end) {
s.Rate = append(s.Rate, Bucket{ k := cur.Format("2006-01-02 15")
Key: cur.Format("01-02 15"), s.Rate = append(s.Rate, Bucket{Key: cur.Format("01-02 15"), Count: hourly[k]})
Count: hourly[cur.Format("2006-01-02 15")], 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) 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 // topBuckets sorts a count map most → least (ties alphabetical) and optionally
// keeps only the top n. // keeps only the top n.
+49 -4
View File
@@ -57,7 +57,7 @@ func TestStatsNoNilSlices(t *testing.T) {
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a // 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. // window that yields no hourly chart must still produce [] and not null.
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC) 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 var s2 Stats
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute)) s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
s2.ensureNonNil() 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), // 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. // then a 2-hour silence, then 3 more.
var times []time.Time var times []entry
for i := 0; i < 10; i++ { 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++ { 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 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 // 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 // months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
// would invent activity that never happened. // would invent activity that never happened.