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, }) }