feat(watchlist): the auto-contest pattern — DXHunter's contest_prefix

A field in the tab's toolbar (e.g. WWA): while non-empty, any spotted callsign
CONTAINING it joins the watchlist as a contest entry by itself — a
special-event fleet (HB9WWA, DL0WWA, F4WWA/P…) is collected as it appears
instead of typed in one by one, and each is judged per UTC day like any contest
entry. Contains, not prefix, because the event string sits anywhere in those
calls.

Global setting, cached in an atomic so the spot pipeline never touches the
settings store per spot. Emptying the field stops the collecting but keeps what
was collected — DXHunter deletes non-matching contest entries on a pattern
change, and a setting that silently empties an operator's list is the one
behaviour of the original this port refuses.

The tab also refreshes on backend auto-adds (event) and on a slow tick, so
last-seen and the counters stay honest while it sits open.
This commit is contained in:
2026-08-29 00:50:14 +02:00
parent c4de5f2ea0
commit 8f64e3fee3
4 changed files with 58 additions and 6 deletions
+9 -6
View File
@@ -96,12 +96,13 @@ const (
keyLookupFailsafe = "lookup.failsafe" keyLookupFailsafe = "lookup.failsafe"
keyLookupImages = "lookup.download_images" // 1 = expose QRZ ImageURL to UI keyLookupImages = "lookup.download_images" // 1 = expose QRZ ImageURL to UI
keyStationCallsign = "station.callsign" keyStationCallsign = "station.callsign"
keyStationOperator = "station.operator" keyStationOperator = "station.operator"
keyStationMyGrid = "station.my_grid" keyWatchlistContestPattern = "watchlist.contest_pattern" // auto-add contest calls containing this
keyStationCountry = "station.my_country" keyStationMyGrid = "station.my_grid"
keyStationSOTA = "station.my_sota_ref" keyStationCountry = "station.my_country"
keyStationPOTA = "station.my_pota_ref" keyStationSOTA = "station.my_sota_ref"
keyStationPOTA = "station.my_pota_ref"
keyListsBands = "lists.bands" keyListsBands = "lists.bands"
keyListsModes = "lists.modes" keyListsModes = "lists.modes"
@@ -739,6 +740,7 @@ type App struct {
watchlist *watchlist.Store // Tools → Watchlist (global watchlist.json) watchlist *watchlist.Store // Tools → Watchlist (global watchlist.json)
watchAlertMu sync.Mutex // throttles watchlist alerts… watchAlertMu sync.Mutex // throttles watchlist alerts…
watchAlertAt map[string]time.Time // …per entry watchAlertAt map[string]time.Time // …per entry
watchPattern atomic.Value // auto-contest pattern (string), loaded at startup
operating *operating.Repo operating *operating.Repo
udp *udp.Manager udp *udp.Manager
udpRepo *udp.Repo udpRepo *udp.Repo
@@ -1391,6 +1393,7 @@ func (a *App) startup(ctx context.Context) {
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) }) a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
a.watchlist = watchlist.New(filepath.Join(a.dataDir, "watchlist.json")) a.watchlist = watchlist.New(filepath.Join(a.dataDir, "watchlist.json"))
a.watchAlertAt = map[string]time.Time{} a.watchAlertAt = map[string]time.Time{}
a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, ""))))
go a.pota.Run(a.ctx) go a.pota.Run(a.ctx)
// DX Cluster (multi-server): the spot callback enriches each spot // DX Cluster (multi-server): the spot callback enriches each spot
+37
View File
@@ -11,12 +11,35 @@ import (
"strings" "strings"
"time" "time"
"hamlog/internal/applog"
"hamlog/internal/qso" "hamlog/internal/qso"
"hamlog/internal/watchlist" "hamlog/internal/watchlist"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime" wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
) )
// The auto-contest pattern, DXHunter's contest_prefix: while non-empty, any
// spotted callsign CONTAINING it is added to the watchlist as a contest entry
// by itself — a special-event fleet (HB9WWA, DL0WWA, F4WWA…) is collected as
// it appears instead of typed in one by one. Held in an atomic so the spot
// pipeline never touches the settings store per spot.
func (a *App) GetWatchlistContestPattern() string {
if v := a.watchPattern.Load(); v != nil {
return v.(string)
}
return ""
}
// SetWatchlistContestPattern stores the pattern (global, like the list itself).
// Emptying it stops the auto-add; entries already collected stay — deleting an
// operator's list because a setting changed is DXHunter behaviour this port
// deliberately drops.
func (a *App) SetWatchlistContestPattern(p string) {
p = strings.ToUpper(strings.TrimSpace(p))
a.watchPattern.Store(p)
a.setSettingGlobal(keyWatchlistContestPattern, p)
}
// WatchlistEntries returns the list for the tab. // WatchlistEntries returns the list for the tab.
func (a *App) WatchlistEntries() []watchlist.Entry { func (a *App) WatchlistEntries() []watchlist.Entry {
if a.watchlist == nil { if a.watchlist == nil {
@@ -160,6 +183,20 @@ func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int6
return return
} }
entry, notify, ok := a.watchlist.MarkSeen(dxCall) entry, notify, ok := a.watchlist.MarkSeen(dxCall)
if !ok {
// Not watched yet — the auto-contest pattern may claim it. Contains, not
// prefix: the event string sits anywhere in these calls (HB9WWA, F4WWA/P).
if p := a.GetWatchlistContestPattern(); p != "" &&
strings.Contains(strings.ToUpper(dxCall), p) {
if err := a.watchlist.Add(dxCall, true); err == nil {
applog.Printf("watchlist: auto-added %s (contest pattern %q)", dxCall, p)
entry, notify, ok = a.watchlist.MarkSeen(dxCall)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "watchlist:changed")
}
}
}
}
if !ok || !notify || a.ctx == nil { if !ok || !notify || a.ctx == nil {
return return
} }
+4
View File
@@ -602,6 +602,8 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>; export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
export function GetWatchlistContestPattern():Promise<string>;
export function GetWebPublishConfig():Promise<webpub.Config>; export function GetWebPublishConfig():Promise<webpub.Config>;
export function GetWebPublishStatus():Promise<main.WebPublishStatus>; export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
@@ -1228,6 +1230,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>; export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SetWatchlistContestPattern(arg1:string):Promise<void>;
export function SetWinkeyerTrace(arg1:boolean):Promise<void>; export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
export function SetWorkedCallVariants(arg1:boolean):Promise<void>; export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
+8
View File
@@ -1142,6 +1142,10 @@ export function GetUltrabeamStatus() {
return window['go']['main']['App']['GetUltrabeamStatus'](); return window['go']['main']['App']['GetUltrabeamStatus']();
} }
export function GetWatchlistContestPattern() {
return window['go']['main']['App']['GetWatchlistContestPattern']();
}
export function GetWebPublishConfig() { export function GetWebPublishConfig() {
return window['go']['main']['App']['GetWebPublishConfig'](); return window['go']['main']['App']['GetWebPublishConfig']();
} }
@@ -2394,6 +2398,10 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1); return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
} }
export function SetWatchlistContestPattern(arg1) {
return window['go']['main']['App']['SetWatchlistContestPattern'](arg1);
}
export function SetWinkeyerTrace(arg1) { export function SetWinkeyerTrace(arg1) {
return window['go']['main']['App']['SetWinkeyerTrace'](arg1); return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
} }