feat: ClubLog Most Wanted rank in the entry band matrix

Opt-in (Settings → General). Shows a "MW #rank" pill next to the DXCC entity
name in the entry-strip band/slot matrix — ClubLog's Most Wanted ranking
(1 = most wanted), personalised to the operator's callsign and refreshed daily.

- internal/clublog/mostwanted.go: fetch mostwanted.php?api=1&callsign=<CALL>
  (rank→DXCC JSON, real User-Agent), invert to dxcc→rank, cache to
  <dataDir>/clublog_mostwanted.json; NeedsRefresh invalidates on callsign change.
- app.go: keyClublogMostWanted setting, a.clublogMW, startup refresh goroutine,
  activeCallsign() helper, and Get/Set/Download bindings mirroring the cty ones.
  WorkedBefore now carries MWRank (qso.WorkedBefore.MWRank) when enabled.
- BandSlotGrid.tsx: MW pill next to the entity name, colour-tiered by rank.
- SettingsModal General: toggle + download button + status, mirroring the
  ClubLog cty-exceptions block.

Also reorganises the changelog: the theme-persistence fix (landed after the
v0.20.11 tag) plus this feature go under a new 0.20.12 entry; 0.20.11 keeps only
what shipped in its binary.
This commit is contained in:
2026-07-23 20:01:28 +02:00
parent 5c4f101402
commit a71e48f811
9 changed files with 418 additions and 3 deletions
+101 -1
View File
@@ -136,6 +136,7 @@ const (
keyAwardEditsSeeded = "awards.defs.editflag" // one-shot: back-fill the user-edited flag
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
keyClublogMostWanted = "clublog.most_wanted" // "1" → show ClubLog Most Wanted rank in the entry matrix
// E-mail / SMTP — send QSO recordings to the correspondent.
keyEmailEnabled = "email.enabled"
@@ -464,6 +465,7 @@ type App struct {
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
@@ -954,6 +956,30 @@ func (a *App) startup(ctx context.Context) {
}
}()
// ClubLog "Most Wanted" DXCC ranking (opt-in, Settings → General). Loaded from
// the cached JSON, then refreshed daily and whenever the operator's callsign
// changes (the ranking is personalised per callsign). Best-effort.
a.clublogMW = clublog.NewMostWanted(dataDir)
go func() {
if a.settings == nil {
return
}
if v, _ := a.settings.Get(a.ctx, keyClublogMostWanted); v != "1" {
return // feature off — don't touch the network
}
_ = a.clublogMW.LoadFromDisk()
call := a.activeCallsign()
if a.clublogMW.NeedsRefresh(call, 24*time.Hour) {
if err := a.clublogMW.Download(context.Background(), call); err != nil {
applog.Printf("clublog most-wanted: auto-refresh failed: %v", err)
}
}
if a.clublogMW.Loaded() {
c, d, n := a.clublogMW.Info()
applog.Printf("clublog most-wanted: loaded %d entities for %s (%s)", n, c, d)
}
}()
// POTA: background poller of api.pota.app so cluster spots can be tagged
// when the DX station is currently activating a park. Best-effort.
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
@@ -5247,7 +5273,13 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err
dxccHint = dxcc.EntityDXCC(m.Entity.Name)
}
}
return a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
wb, err := a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
// Attach the ClubLog Most Wanted rank for this entity (opt-in) so the entry
// matrix can show it next to the country name.
if err == nil && wb.DXCC > 0 && a.clublogMW != nil && a.clublogMostWantedEnabled() {
wb.MWRank = a.clublogMW.Rank(wb.DXCC)
}
return wb, err
}
// SetCompactMode toggles a tiny always-on-top window that exposes just the
@@ -7327,6 +7359,74 @@ func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
return a.GetClublogCtyInfo(), nil
}
// activeCallsign is the current profile's callsign (upper-cased), "" if none.
func (a *App) activeCallsign() string {
if a.profiles == nil {
return ""
}
if p, err := a.profiles.Active(a.ctx); err == nil {
return strings.ToUpper(strings.TrimSpace(p.Callsign))
}
return ""
}
func (a *App) clublogMostWantedEnabled() bool {
if a.settings == nil {
return false
}
v, _ := a.settings.Get(a.ctx, keyClublogMostWanted)
return v == "1"
}
// ClublogMostWantedInfo is the UI status of the ClubLog Most Wanted data.
type ClublogMostWantedInfo struct {
Enabled bool `json:"enabled"`
Loaded bool `json:"loaded"`
Callsign string `json:"callsign"`
Date string `json:"date"`
Count int `json:"count"`
}
// GetClublogMostWantedInfo returns the current Most Wanted status.
func (a *App) GetClublogMostWantedInfo() ClublogMostWantedInfo {
info := ClublogMostWantedInfo{Enabled: a.clublogMostWantedEnabled()}
if a.clublogMW != nil {
info.Loaded = a.clublogMW.Loaded()
info.Callsign, info.Date, info.Count = a.clublogMW.Info()
}
return info
}
// SetClublogMostWantedEnabled toggles the Most Wanted feature. On first enable it
// loads the cached list (the UI then calls Download to fetch a fresh one).
func (a *App) SetClublogMostWantedEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
v := "0"
if on {
v = "1"
}
if err := a.settings.Set(a.ctx, keyClublogMostWanted, v); err != nil {
return err
}
if on && a.clublogMW != nil && !a.clublogMW.Loaded() {
_ = a.clublogMW.LoadFromDisk() // ok if not downloaded yet
}
return nil
}
// DownloadClublogMostWanted fetches a fresh Most Wanted list for the active call.
func (a *App) DownloadClublogMostWanted() (ClublogMostWantedInfo, error) {
if a.clublogMW == nil {
return ClublogMostWantedInfo{}, fmt.Errorf("clublog not initialized")
}
if err := a.clublogMW.Download(a.ctx, a.activeCallsign()); err != nil {
return a.GetClublogMostWantedInfo(), err
}
return a.GetClublogMostWantedInfo(), nil
}
// applyClublogException overrides a QSO's entity fields from a ClubLog
// exception matching its callsign at its date. force=true ignores the
// enable toggle (used by the explicit "Update from ClubLog" action).