From 443698b5070bf6354b84670d573b44cae481e496 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 18 Jul 2026 17:11:52 +0200 Subject: [PATCH] feat: per-operator filter in the statistics dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app.go | 14 +++++- frontend/src/components/StatsPanel.tsx | 28 +++++++++--- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 4 +- frontend/wailsjs/go/main/App.js | 8 +++- internal/qso/stats.go | 60 +++++++++++++++++++++++--- 6 files changed, 99 insertions(+), 19 deletions(-) diff --git a/app.go b/app.go index 0c18158..8b71bfe 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/frontend/src/components/StatsPanel.tsx b/frontend/src/components/StatsPanel.tsx index 89e88dc..af2303a 100644 --- a/frontend/src/components/StatsPanel.tsx +++ b/frontend/src/components/StatsPanel.tsx @@ -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([]); const [contest, setContest] = useState(''); + // Operator picker: '' = all operators, "—" = station owner (empty OPERATOR). + const [operators, setOperators] = useState([]); + 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() { ))} + {/* 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 && ( + + )} +
{([ ['all', t('stats.pAll')], ['ytd', t('stats.pYTD')], diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 90a838e..1a07007 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.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 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.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 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/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index ea2ddb1..b9883ba 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -356,7 +356,7 @@ export function GetLoTWUsersStatus():Promise; export function GetLogFilePath():Promise; -export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise; +export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number,arg5:string):Promise; export function GetLogbookRevision():Promise; @@ -368,6 +368,8 @@ export function GetOfflineStatus():Promise; export function GetOnlineOperators():Promise>; +export function GetOperators():Promise>; + export function GetPGXLSettings():Promise; export function GetPGXLStatus():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index b80ec16..91a152c 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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'](); } diff --git a/internal/qso/stats.go b/internal/qso/stats.go index 4f9867e..7931ecb 100644 --- a/internal/qso/stats.go +++ b/internal/qso/stats.go @@ -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]++