The concept, transplanted: a list of callsigns or prefixes being hunted, matched against the live spot stream, one card per entry with the spots underneath and the two questions that matter answered on every line — is this slot still needed, and what is it worth (the cluster's own NEW badges, read from the same status index). The file is DXHunter's own watchlist.json, field for field, ClubLog block included though phase 2 will fill it — a file that round-trips unchanged is the whole of 'same format', and a test pins it with a real DXHunter entry. Global (dataDir), not per profile. CONTEST is per entry, not the global mode DXHunter has: a contest entry is judged against the current UTC day — the boundary lives in the query's date bound, so midnight needs no timer and resets nothing. Normal entries read the same in-memory worked index the alerts use. Prefix matching is why RI0SP catches RI0SP/MM, pinned by test. Notify goes through the existing alert:fired event — the frontend already toasts and sounds it — throttled to one alert per entry per two minutes, because a DXpedition lights every skimmer on the planet. Tab wired like NET Control: opt-in from Tools, persisted, closable. Single click fills the callsign, double click works the spot — the cluster's own gesture, kept.
146 lines
4.4 KiB
Go
146 lines
4.4 KiB
Go
package main
|
|
|
|
// Watchlist bindings — the DXHunter watchlist concept as an OpsLog tab.
|
|
// The store lives in internal/watchlist (global watchlist.json, DXHunter's own
|
|
// schema); this file is the Wails boundary plus the two places the list meets
|
|
// the rest of the app: the spot stream (MarkSeen + alert) and the logbook (the
|
|
// worked-today answer contest entries are judged by).
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlog/internal/qso"
|
|
"hamlog/internal/watchlist"
|
|
|
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// WatchlistEntries returns the list for the tab.
|
|
func (a *App) WatchlistEntries() []watchlist.Entry {
|
|
if a.watchlist == nil {
|
|
return nil
|
|
}
|
|
return a.watchlist.Entries()
|
|
}
|
|
|
|
// WatchlistAdd adds a callsign or prefix; contest entries are judged per UTC day.
|
|
func (a *App) WatchlistAdd(callsign string, contest bool) error {
|
|
if a.watchlist == nil {
|
|
return fmt.Errorf("watchlist not initialized")
|
|
}
|
|
return a.watchlist.Add(callsign, contest)
|
|
}
|
|
|
|
// WatchlistRemove deletes an entry.
|
|
func (a *App) WatchlistRemove(callsign string) error {
|
|
if a.watchlist == nil {
|
|
return fmt.Errorf("watchlist not initialized")
|
|
}
|
|
return a.watchlist.Remove(callsign)
|
|
}
|
|
|
|
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
|
func (a *App) WatchlistSetNotify(callsign string, on bool) error {
|
|
if a.watchlist == nil {
|
|
return fmt.Errorf("watchlist not initialized")
|
|
}
|
|
return a.watchlist.SetNotify(callsign, on)
|
|
}
|
|
|
|
// WatchlistSetContest flips the per-entry contest rule.
|
|
func (a *App) WatchlistSetContest(callsign string, on bool) error {
|
|
if a.watchlist == nil {
|
|
return fmt.Errorf("watchlist not initialized")
|
|
}
|
|
return a.watchlist.SetContest(callsign, on)
|
|
}
|
|
|
|
// WatchlistSlotQuery asks whether one spot's slot is worked — against the whole
|
|
// log for a normal entry, against TODAY (UTC) for a contest one.
|
|
type WatchlistSlotQuery struct {
|
|
Call string `json:"call"`
|
|
Band string `json:"band"`
|
|
Mode string `json:"mode"`
|
|
Contest bool `json:"contest"`
|
|
}
|
|
|
|
// WatchlistWorkedSlots answers a batch of slot questions for the tab.
|
|
//
|
|
// One pass over today's contacts and the in-memory worked index rather than a
|
|
// query per spot: the tab refreshes on every spot burst, and a busy evening
|
|
// must not turn into a query storm. Contest entries read the TODAY set — the
|
|
// midnight-UTC reset is the query's date bound, nothing stored, nothing to
|
|
// reset. Mode is compared at CLASS grain (FT8 and FT4 are both Digital),
|
|
// matching how the cluster's own worked_slot judges a slot.
|
|
func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool {
|
|
out := make([]bool, len(queries))
|
|
if a.qso == nil || len(queries) == 0 {
|
|
return out
|
|
}
|
|
// Today's slots, only if some entry needs them.
|
|
needToday := false
|
|
for _, q := range queries {
|
|
if q.Contest {
|
|
needToday = true
|
|
break
|
|
}
|
|
}
|
|
today := map[string]bool{}
|
|
if needToday {
|
|
midnight := time.Now().UTC().Truncate(24 * time.Hour)
|
|
rows, err := a.qso.SlotsSince(a.ctx, midnight)
|
|
if err == nil {
|
|
for _, r := range rows {
|
|
today[wcbmKey(r.Callsign, r.Band, qso.ModeClass(r.Mode))] = true
|
|
}
|
|
}
|
|
}
|
|
for i, q := range queries {
|
|
key := wcbmKey(q.Call, q.Band, qso.ModeClass(q.Mode))
|
|
if q.Contest {
|
|
out[i] = today[key]
|
|
} else {
|
|
out[i] = a.isWorkedBandMode(q.Call, q.Band, qso.ModeClass(q.Mode))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// watchSpot runs one live spot through the watchlist: last-seen bookkeeping and
|
|
// the alert, through the SAME event the alert rules fire — the frontend already
|
|
// knows how to toast and sound it, and a second notification path would be a
|
|
// second thing to misconfigure.
|
|
func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int64) {
|
|
if a.watchlist == nil {
|
|
return
|
|
}
|
|
entry, notify, ok := a.watchlist.MarkSeen(dxCall)
|
|
if !ok || !notify || a.ctx == nil {
|
|
return
|
|
}
|
|
// Throttled per entry: a DXpedition lights up every skimmer on the planet,
|
|
// and forty alerts a minute for one station is a alarm nobody keeps on.
|
|
a.watchAlertMu.Lock()
|
|
last := a.watchAlertAt[entry]
|
|
now := time.Now()
|
|
if now.Sub(last) < 2*time.Minute {
|
|
a.watchAlertMu.Unlock()
|
|
return
|
|
}
|
|
a.watchAlertAt[entry] = now
|
|
a.watchAlertMu.Unlock()
|
|
wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
|
|
"rule": "Watchlist " + entry,
|
|
"call": strings.ToUpper(strings.TrimSpace(dxCall)),
|
|
"band": band,
|
|
"mode": mode,
|
|
"freq_hz": freqHz,
|
|
"country": country,
|
|
"comment": comment,
|
|
"sound": true,
|
|
"visual": true,
|
|
})
|
|
}
|