The first version asked the alerts' worked-index — keyed by RAW mode (FT8) — with a CLASS name (DIGI). Nothing ever matched, and a DXpedition worked on five bands showed every slot as Needed. The verdict now reads the cluster's own WorkedCallSlotKeys set, built in the cached status maps (now unconditionally — it used to exist only when a display option wanted it). Same index, same mode normalisation as the grid, so the watchlist can never contradict the cluster about the same spot. The contest today-set is normalised through the same function. And the cards column is capped at a reading width instead of stretching callsign-to-badge lines across the whole window.
165 lines
5.0 KiB
Go
165 lines
5.0 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/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.
|
|
//
|
|
// Normal entries read the CLUSTER's own slot set — the same cached
|
|
// WorkedCallSlotKeys index that colours the grid — so the watchlist can never
|
|
// contradict the cluster about the same spot. That index normalises the mode
|
|
// exactly as the operator configured (digital grouping on → FT4 counts as FT8's
|
|
// class; off → exact mode), and the first version of this ignored that: it
|
|
// asked the alerts' raw-mode index with a CLASS name, matched nothing, and
|
|
// showed a fully-worked DXpedition as all Needed.
|
|
//
|
|
// Contest entries read TODAY's contacts instead — the midnight-UTC reset is
|
|
// the query's date bound, nothing stored, nothing to reset — normalised through
|
|
// the same function so the two answers use one grammar.
|
|
func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool {
|
|
out := make([]bool, len(queries))
|
|
if a.qso == nil || len(queries) == 0 {
|
|
return out
|
|
}
|
|
idx := a.clusterStatusMaps()
|
|
norm := func(m string) string {
|
|
m = strings.ToUpper(strings.TrimSpace(m))
|
|
if idx.normMode != nil {
|
|
m = idx.normMode(m)
|
|
}
|
|
return m
|
|
}
|
|
slotKey := func(call, band, mode string) string {
|
|
up := strings.ToUpper(strings.TrimSpace(call))
|
|
b := strings.ToLower(strings.TrimSpace(band))
|
|
if m := norm(mode); m != "" {
|
|
return up + "|" + b + "|" + m
|
|
}
|
|
return up + "|" + b
|
|
}
|
|
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[slotKey(r.Callsign, r.Band, r.Mode)] = true
|
|
}
|
|
}
|
|
}
|
|
for i, q := range queries {
|
|
key := slotKey(q.Call, q.Band, q.Mode)
|
|
if q.Contest {
|
|
out[i] = today[key]
|
|
} else if idx.workedCallSlots != nil {
|
|
_, out[i] = idx.workedCallSlots[key]
|
|
}
|
|
}
|
|
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,
|
|
})
|
|
}
|