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:
@@ -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
|
// 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
|
// is stretched to 23:59:59 of that day, otherwise picking today as the end would
|
||||||
// silently drop today's QSOs.
|
// 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 {
|
if a.qso == nil {
|
||||||
return qso.Stats{}, fmt.Errorf("db not initialized")
|
return qso.Stats{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
from := parseStatsBound(fromISO, false)
|
from := parseStatsBound(fromISO, false)
|
||||||
to := parseStatsBound(toISO, true)
|
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
|
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-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 { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
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.
|
// that contest AND lets the window derive from its own span — no date typing.
|
||||||
const [runs, setRuns] = useState<ContestRun[]>([]);
|
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||||
const [contest, setContest] = useState('');
|
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(() => { 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
|
// 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
|
// with it — the backend derives them. Sending a period as well would be two
|
||||||
// filters fighting over the same axis.
|
// filters fighting over the same axis.
|
||||||
@@ -502,7 +506,7 @@ export function StatsPanel() {
|
|||||||
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||||
setBusy(true); setErr('');
|
setBusy(true); setErr('');
|
||||||
try {
|
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
|
// 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
|
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||||
// once, here, rather than guarding at every use site and missing one.
|
// 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.
|
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contest && period === 'custom' && !(from && to)) return;
|
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
|
// 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.
|
// 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"
|
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||||
@@ -570,6 +574,20 @@ export function StatsPanel() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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')}>
|
<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')],
|
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||||
|
|||||||
@@ -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.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.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',
|
||||||
@@ -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.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.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.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.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, …).',
|
'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',
|
'msg.expand': 'Cliquer pour lire le message en entier',
|
||||||
|
|||||||
Vendored
+3
-1
@@ -356,7 +356,7 @@ export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
|||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
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>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
@@ -368,6 +368,8 @@ export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
|||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
|
export function GetOperators():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
|
|
||||||
export function GetPGXLStatus():Promise<powergenius.Status>;
|
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||||
|
|||||||
@@ -670,8 +670,8 @@ export function GetLogFilePath() {
|
|||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLogStats(arg1, arg2, arg3, arg4) {
|
export function GetLogStats(arg1, arg2, arg3, arg4, arg5) {
|
||||||
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4, arg5);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLogbookRevision() {
|
export function GetLogbookRevision() {
|
||||||
@@ -694,6 +694,10 @@ export function GetOnlineOperators() {
|
|||||||
return window['go']['main']['App']['GetOnlineOperators']();
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOperators() {
|
||||||
|
return window['go']['main']['App']['GetOperators']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPGXLSettings() {
|
export function GetPGXLSettings() {
|
||||||
return window['go']['main']['App']['GetPGXLSettings']();
|
return window['go']['main']['App']['GetPGXLSettings']();
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-7
@@ -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
|
// 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
|
// span — so rate, best-hour and off-air figures are computed over the contest
|
||||||
// itself without the operator having to look its dates up.
|
// 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
|
var s Stats
|
||||||
contestID = strings.ToUpper(strings.TrimSpace(contestID))
|
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, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
|
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
|
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
|
// 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/
|
// this after the counting (the obvious mistake) would leave the mode/band/
|
||||||
// operator charts showing the whole log while only the trend was filtered.
|
// 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 != "" {
|
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||||
bandC[b]++
|
bandC[b]++
|
||||||
}
|
}
|
||||||
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
// op was resolved above (with the operator filter applied).
|
||||||
// it explicitly rather than dropping the QSO from the operator chart.
|
|
||||||
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
|
||||||
if op == "" {
|
|
||||||
op = "—"
|
|
||||||
}
|
|
||||||
opC[op]++
|
opC[op]++
|
||||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||||
stationC[st]++
|
stationC[st]++
|
||||||
|
|||||||
Reference in New Issue
Block a user