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
+}