7.0589 falls in the band plan's generic [7.04-7.1] DATA segment — an F/H DXpedition off the standard dials — and 'DATA' is in nobody's log, so the spot read NEW MODE / Needed while the FT8 contact sat right there. A generic mode cannot be matched exactly; it is now judged at digital-class grain — worked if ANY digital mode of that call is logged on that band — against a second class-folded slot set built beside the exact one (shared when the grouping option already folds them). DXHunter answers this with a finer frequency table, which fails on exactly these off-dial expeditions; the class rule cannot. The toolbar also wraps as a whole with tighter gaps instead of dropping its last filter chip onto a lone second line.
189 lines
6.0 KiB
Go
189 lines
6.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/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.
|
|
//
|
|
// 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
|
|
}
|
|
// A spot whose mode the band plan could only call "DATA" (an F/H DXpedition
|
|
// off the standard dials, a comment with no mode) cannot be matched exactly:
|
|
// "DATA" is in nobody's log. Such a spot is judged at digital-CLASS grain —
|
|
// worked if ANY digital mode of that call is logged on that band. Claiming
|
|
// NEW MODE because the label differs from FT8 was the reported bug.
|
|
generic := func(m string) bool {
|
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
|
case "", "DATA", "DIG", "DIGI", "DIGITAL":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
digKey := func(call, band string) string {
|
|
return strings.ToUpper(strings.TrimSpace(call)) + "|" + strings.ToLower(strings.TrimSpace(band)) + "|DIG"
|
|
}
|
|
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
|
|
today[digKey(r.Callsign, r.Band)] = qso.ModeClass(r.Mode) == "DIG" || today[digKey(r.Callsign, r.Band)]
|
|
}
|
|
}
|
|
}
|
|
for i, q := range queries {
|
|
if q.Contest {
|
|
if generic(q.Mode) {
|
|
out[i] = today[digKey(q.Call, q.Band)]
|
|
} else {
|
|
out[i] = today[slotKey(q.Call, q.Band, q.Mode)]
|
|
}
|
|
} else if generic(q.Mode) {
|
|
if idx.workedCallSlotsDig != nil {
|
|
_, out[i] = idx.workedCallSlotsDig[digKey(q.Call, q.Band)]
|
|
}
|
|
} else if idx.workedCallSlots != nil {
|
|
_, out[i] = idx.workedCallSlots[slotKey(q.Call, q.Band, 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,
|
|
})
|
|
}
|