feat: per-operator filter in the statistics dashboard

New Operator picker next to the contest picker, populated with the operators
actually present in the log. Selecting one recomputes every statistic for that
operator — QSO/DXCC totals, top countries, continent split, by band/mode, and
the rate/best-60. "— Station owner —" buckets QSOs logged with no OPERATOR set.
Shown only when the log has more than one operator.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-18 17:11:52 +02:00
co-authored by Claude Opus 4.8
parent db2908b3ef
commit 443698b507
6 changed files with 99 additions and 19 deletions
+12 -2
View File
@@ -4481,13 +4481,23 @@ func (a *App) UpdateQSO(q qso.QSO) error {
// bound", so two empty strings mean the whole log. `to` is inclusive: a bare date
// is stretched to 23:59:59 of that day, otherwise picking today as the end would
// silently drop today's QSOs.
func (a *App) GetLogStats(fromISO, toISO, contestID string, year int) (qso.Stats, error) {
func (a *App) GetLogStats(fromISO, toISO, contestID string, year int, operator string) (qso.Stats, error) {
if a.qso == nil {
return qso.Stats{}, fmt.Errorf("db not initialized")
}
from := parseStatsBound(fromISO, false)
to := parseStatsBound(toISO, true)
return a.qso.Stats(a.ctx, from, to, contestID, year)
return a.qso.Stats(a.ctx, from, to, contestID, year, operator)
}
// GetOperators lists the distinct operators present in the log (upper-cased),
// for the Statistics operator picker. "—" stands for QSOs the station owner
// logged himself (empty OPERATOR). Sorted, owner-bucket last.
func (a *App) GetOperators() ([]string, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
return a.qso.Operators(a.ctx)
}
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
+23 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
import { GetLogStats, GetContestRuns, GetOperators } from '../../wailsjs/go/main/App';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
@@ -491,10 +491,14 @@ export function StatsPanel() {
// that contest AND lets the window derive from its own span — no date typing.
const [runs, setRuns] = useState<ContestRun[]>([]);
const [contest, setContest] = useState('');
// Operator picker: '' = all operators, "—" = station owner (empty OPERATOR).
const [operators, setOperators] = useState<string[]>([]);
const [operator, setOperator] = useState('');
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
useEffect(() => { GetOperators().then((o: any) => setOperators((o ?? []) as string[])).catch(() => {}); }, []);
const load = async (p: Period = period, f = from, t2 = to, c = contest) => {
const load = async (p: Period = period, f = from, t2 = to, c = contest, op = operator) => {
// A contest defines its own window (its first→last QSO), so we send no dates
// with it — the backend derives them. Sending a period as well would be two
// filters fighting over the same axis.
@@ -502,7 +506,7 @@ export function StatsPanel() {
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
setBusy(true); setErr('');
try {
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0, op)) as any;
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
// .length on null unmounts the whole React tree — a white screen. Normalise
// once, here, rather than guarding at every use site and missing one.
@@ -523,9 +527,9 @@ export function StatsPanel() {
// are set — otherwise every keystroke in the date box would re-scan the log.
useEffect(() => {
if (!contest && period === 'custom' && !(from && to)) return;
load(period, from, to, contest);
load(period, from, to, contest, operator);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [period, from, to, contest]);
}, [period, from, to, contest, operator]);
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
@@ -570,6 +574,20 @@ export function StatsPanel() {
))}
</select>
{/* Operator picker — narrows every stat (totals, DXCC, top countries,
continent split, rate) to one operator from the log. "—" = the
station owner (QSOs logged with no OPERATOR set). */}
{operators.length > 1 && (
<select value={operator} onChange={(e) => setOperator(e.target.value)}
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[180px]"
title={t('stats.operatorTip')}>
<option value="">{t('stats.allOperators')}</option>
{operators.map((op) => (
<option key={op} value={op}>{op === '—' ? t('stats.stationOwner') : op}</option>
))}
</select>
)}
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
{([
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
+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.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.rateSheet': 'Rate sheet', 'stats.rateSheetSub': 'Hour by hour — who made the QSOs (silent hours omitted)', '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 —', 'stats.allOperators': '— All operators —', 'stats.stationOwner': '— Station owner —', 'stats.operatorTip': 'Narrow every statistic to one operator from the log.',
'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',
@@ -346,7 +346,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 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.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 —',
'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 —', 'stats.allOperators': '— Tous les opérateurs —', 'stats.stationOwner': '— Propriétaire station —', 'stats.operatorTip': "Restreindre toutes les statistiques à un opérateur du log.",
'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, …).',
'msg.expand': 'Cliquer pour lire le message en entier',
+3 -1
View File
@@ -356,7 +356,7 @@ export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
export function GetLogFilePath():Promise<string>;
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise<qso.Stats>;
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number,arg5:string):Promise<qso.Stats>;
export function GetLogbookRevision():Promise<string>;
@@ -368,6 +368,8 @@ export function GetOfflineStatus():Promise<main.OfflineStatus>;
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
export function GetOperators():Promise<Array<string>>;
export function GetPGXLSettings():Promise<main.PGXLSettings>;
export function GetPGXLStatus():Promise<powergenius.Status>;
+6 -2
View File
@@ -670,8 +670,8 @@ export function GetLogFilePath() {
return window['go']['main']['App']['GetLogFilePath']();
}
export function GetLogStats(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
export function GetLogStats(arg1, arg2, arg3, arg4, arg5) {
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4, arg5);
}
export function GetLogbookRevision() {
@@ -694,6 +694,10 @@ export function GetOnlineOperators() {
return window['go']['main']['App']['GetOnlineOperators']();
}
export function GetOperators() {
return window['go']['main']['App']['GetOperators']();
}
export function GetPGXLSettings() {
return window['go']['main']['App']['GetPGXLSettings']();
}
+53 -7
View File
@@ -210,9 +210,49 @@ func yes(s string) bool {
// 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) {
// Operators returns the distinct operators in the log (upper-cased), with "—"
// for QSOs the station owner logged himself (empty OPERATOR). Sorted, with "—"
// last so the picker reads real callsigns first.
func (r *Repo) Operators(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT COALESCE(operator,'') FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
seen := map[string]struct{}{}
for rows.Next() {
var op string
if err := rows.Scan(&op); err != nil {
return nil, err
}
op = strings.ToUpper(strings.TrimSpace(op))
if op == "" {
op = "—"
}
seen[op] = struct{}{}
}
out := make([]string, 0, len(seen))
hasOwner := false
for op := range seen {
if op == "—" {
hasOwner = true
continue
}
out = append(out, op)
}
sort.Strings(out)
if hasOwner {
out = append(out, "—")
}
return out, rows.Err()
}
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int, operator string) (Stats, error) {
var s Stats
contestID = strings.ToUpper(strings.TrimSpace(contestID))
// Operator filter: "" = all operators; "—" = QSOs the station owner logged
// himself (empty OPERATOR); any other value = that operator's QSOs only.
opFilter := strings.ToUpper(strings.TrimSpace(operator))
rows, err := r.db.QueryContext(ctx, `
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
@@ -257,6 +297,17 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
continue
}
// An empty OPERATOR means "the station owner logged it himself" — bucket
// it as "—". Computed here so the operator filter can also drop QSOs that
// aren't this operator's before they reach ANY bucket.
op := strings.ToUpper(strings.TrimSpace(oper.String))
if op == "" {
op = "—"
}
if opFilter != "" && op != opFilter {
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.
@@ -288,12 +339,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
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 = "—"
}
// op was resolved above (with the operator filter applied).
opC[op]++
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
stationC[st]++