From e93d2d36a8990b3870298ddabbe76c0661111cdb Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 29 Aug 2026 01:49:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(watchlist):=20the=20ClubLog=20expedition?= =?UTF-8?q?=20enrichment=20=E2=80=94=20phase=202=20lands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clublog.org/watch.php, per entry: the DXpedition flag, OQRS, LiveStream, the log's QSO total and the last-24h rate — the fields the schema has carried since phase 1. Refreshed on DXHunter's own cadence (hourly for expeditions, six-hourly for the rest), two at a time with a breath between requests: the application API key is shared by every install, so a hundred-entry list must read as a trickle at ClubLog's end. Nothing to configure — OpsLog's own key, already used for cty and Most Wanted, serves. A starred pattern is asked about by its base (ClubLog has no log for VK9*). The card gains the 24h rate beside the QSO total and the Live link to the expedition's stream. --- app.go | 1 + app_watchlist.go | 57 ++++++++++ frontend/src/components/WatchlistTab.tsx | 8 +- internal/watchlist/clublog.go | 131 +++++++++++++++++++++++ 4 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 internal/watchlist/clublog.go diff --git a/app.go b/app.go index 56c69e5..72e0f60 100644 --- a/app.go +++ b/app.go @@ -1398,6 +1398,7 @@ func (a *App) startup(ctx context.Context) { // 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...) }) + a.startWatchlistClubLog() a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, "")))) go a.pota.Run(a.ctx) diff --git a/app_watchlist.go b/app_watchlist.go index 16c77f3..d93eee2 100644 --- a/app_watchlist.go +++ b/app_watchlist.go @@ -7,7 +7,9 @@ package main // worked-today answer contest entries are judged by). import ( + "context" "fmt" + "net/http" "strings" "time" @@ -234,3 +236,58 @@ func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int6 "visual": true, }) } + +// startWatchlistClubLog begins the expedition enrichment: every entry's +// ClubLog block (expedition flag, OQRS, LiveStream, QSO totals and 24 h rate) +// is refreshed on a rolling schedule — hourly for expeditions, six-hourly for +// the rest, DXHunter's own cadence. OpsLog's application API key is used, so +// there is nothing to configure. +// +// Two at a time with a breath between requests: the key is shared by every +// OpsLog install, and a hundred-entry watchlist must read as a trickle at +// ClubLog's end, not a burst. +func (a *App) startWatchlistClubLog() { + if a.watchlist == nil { + return + } + go func() { + client := &http.Client{Timeout: 30 * time.Second} + // Let the app finish starting before the first network pass. + time.Sleep(15 * time.Second) + for { + stale := a.watchlist.StaleEntries(1*time.Hour, 6*time.Hour) + changed := false + for _, call := range stale { + // The pattern's BASE is what ClubLog knows: VK9* has no log. + base := strings.TrimSuffix(call, "*") + if base == "" { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + d, err := watchlist.FetchWatch(ctx, client, clublogAppAPIKey, base) + cancel() + if err != nil { + applog.Printf("watchlist clublog: %s: %v", base, err) + continue + } + if a.watchlist.ApplyClubLog(call, d) { + changed = true + if d.IsExpedition { + applog.Printf("watchlist clublog: %s is a DXpedition (%d QSOs, %d/24h, OQRS=%v)", + base, func() int { + if d.ClubLogInfo != nil { + return d.ClubLogInfo.TotalQSOs + } + return 0 + }(), d.QSOs24h(), d.HasOQRS) + } + } + time.Sleep(400 * time.Millisecond) + } + if changed && a.ctx != nil { + wruntime.EventsEmit(a.ctx, "watchlist:changed") + } + time.Sleep(10 * time.Minute) + } + }() +} diff --git a/frontend/src/components/WatchlistTab.tsx b/frontend/src/components/WatchlistTab.tsx index c9af876..fb1b346 100644 --- a/frontend/src/components/WatchlistTab.tsx +++ b/frontend/src/components/WatchlistTab.tsx @@ -20,7 +20,7 @@ import { useI18n } from '@/lib/i18n'; import { WatchlistEntries, WatchlistAdd, WatchlistRemove, WatchlistSetNotify, WatchlistSetContest, WatchlistWorkedSlots, - GetWatchlistContestPattern, SetWatchlistContestPattern, + GetWatchlistContestPattern, SetWatchlistContestPattern, OpenExternalURL, } from '../../wailsjs/go/main/App'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid'; @@ -341,8 +341,12 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P )} {e.isExpedition && chip('var(--chart-5)', t('wl.expedition'))} - {e.clubLogTotalQSOs > 0 && {e.clubLogTotalQSOs.toLocaleString()} QSOs} + {e.clubLogTotalQSOs > 0 && {e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}} {e.clubLogHasOQRS && chip('var(--success)', 'OQRS')} + {e.clubLogLiveStream && ( + { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }} + className="px-1.5 py-0.5 rounded text-[10px] font-bold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live + )} {list.length > 0 && (needed > 0 ? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed })) : chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))} diff --git a/internal/watchlist/clublog.go b/internal/watchlist/clublog.go new file mode 100644 index 0000000..b70873f --- /dev/null +++ b/internal/watchlist/clublog.go @@ -0,0 +1,131 @@ +package watchlist + +// ClubLog enrichment — phase 2 of the DXHunter port. clublog.org/watch.php +// answers, per callsign: is this an expedition, does it run OQRS, is there a +// LiveStream, the log's QSO total and the last 24 hours' rate. That block has +// been in the Entry schema since phase 1 (the file round-trips DXHunter's); +// this file finally fills it. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// WatchData is clublog.org/watch.php's answer, reduced to what the tab shows. +type WatchData struct { + IsExpedition bool `json:"is_expedition"` + HasOQRS bool `json:"has_oqrs"` + LiveStream bool `json:"livestream"` + ClubLogInfo *struct { + TotalQSOs int `json:"total_qsos"` + } `json:"clublog_info"` + // {} when populated, [] when empty — RawMessage swallows both shapes. + QSOsPerBand json.RawMessage `json:"24h_qsos_per_band_mode"` +} + +// QSOs24h sums the per-band/per-mode counts. +func (d *WatchData) QSOs24h() int { + if len(d.QSOsPerBand) == 0 { + return 0 + } + var parsed map[string]map[string]int + if err := json.Unmarshal(d.QSOsPerBand, &parsed); err != nil { + return 0 // "[]" for an empty answer lands here, which is the right zero + } + n := 0 + for _, modes := range parsed { + for _, c := range modes { + n += c + } + } + return n +} + +// FetchWatch asks ClubLog about one callsign. A 404 is "ClubLog has no log for +// this call" — an ordinary answer, not an error. +func FetchWatch(ctx context.Context, client *http.Client, apiKey, callsign string) (*WatchData, error) { + url := fmt.Sprintf("https://clublog.org/watch.php?call=%s&api=%s", + strings.ToUpper(strings.TrimSpace(callsign)), apiKey) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "OpsLog") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return &WatchData{}, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("clublog watch %s: HTTP %d", callsign, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if len(strings.TrimSpace(string(body))) == 0 { + return &WatchData{}, nil + } + var data WatchData + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("clublog watch %s: %w", callsign, err) + } + return &data, nil +} + +// ApplyClubLog writes one answer into the entry and reports whether anything +// the tab shows actually changed — the caller only broadcasts on true. +func (s *Store) ApplyClubLog(callsign string, d *WatchData) bool { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.entries[strings.ToUpper(strings.TrimSpace(callsign))] + if !ok { + return false + } + total := e.ClubLogTotalQSOs + if d.ClubLogInfo != nil { + total = d.ClubLogInfo.TotalQSOs + } + q24 := d.QSOs24h() + changed := e.IsExpedition != d.IsExpedition || e.ClubLogHasOQRS != d.HasOQRS || + e.ClubLogLiveStream != d.LiveStream || e.ClubLogTotalQSOs != total || + e.ClubLogQSOs24h != q24 + e.IsExpedition = d.IsExpedition + e.ClubLogHasOQRS = d.HasOQRS + e.ClubLogLiveStream = d.LiveStream + e.ClubLogTotalQSOs = total + e.ClubLogQSOs24h = q24 + e.ClubLogUpdatedAt = time.Now() + s.scheduleSave() + return changed +} + +// StaleEntries lists the callsigns whose ClubLog block is due a refresh. +// Expeditions age faster — their totals are the interesting number and move by +// the hour; a quiet call is asked about four times a day at most. +func (s *Store) StaleEntries(expTTL, otherTTL time.Duration) []string { + s.mu.RLock() + defer s.mu.RUnlock() + now := time.Now() + var out []string + for call, e := range s.entries { + // A starred pattern is a family, not a station — ClubLog has no log + // for "VK9*". The base (star stripped) is what gets asked about. + ttl := otherTTL + if e.IsExpedition { + ttl = expTTL + } + if now.Sub(e.ClubLogUpdatedAt) >= ttl { + out = append(out, call) + } + } + return out +}