DXHunter's header row, ported: Watchlist / Active / Needed counts up front and the All-Modes select beside the other filters (DIGI matches the digital class, SSB folds USB/LSB). Counters, card lists and the Active/Needed-only filters all read the same mode-filtered view, so the numbers add up to what is on screen. And the notify alert now asks the SAME worked-slot question the tab asks — before making a sound. It used to fire on the raw spot while the tab's verdict arrived on a debounce, so the bell rang for a slot that showed Worked a moment later. Judged in the backend at emit time: exact slot for named modes, digital class for generic DATA, today-only for contest entries.
249 lines
7.2 KiB
Go
249 lines
7.2 KiB
Go
// Package watchlist is the DXHunter watchlist, transplanted: a list of
|
|
// callsigns (or prefixes) the operator is hunting, matched against the live
|
|
// spot stream, with per-entry state that survives restarts.
|
|
//
|
|
// The JSON file is DXHunter's OWN schema, field for field — including the
|
|
// ClubLog expedition block this phase does not fill yet. Deliberate: the
|
|
// operator asked to carry their watchlist.json across, and a file that
|
|
// round-trips unchanged is the whole of "same format". The file is GLOBAL
|
|
// (dataDir, beside nets.json), not per profile: a DXpedition worth hunting is
|
|
// worth hunting whichever station is on.
|
|
//
|
|
// The CONTEST idea is per entry, not a global mode as in DXHunter: an entry
|
|
// marked contest is judged against the CURRENT UTC DAY — at midnight UTC
|
|
// yesterday's contacts stop counting and every slot reads "work today" again.
|
|
// Nothing resets and nothing is stored for it: the day boundary lives in the
|
|
// query, which is why it cannot drift or need a timer.
|
|
package watchlist
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Entry is one watched callsign. JSON tags match DXHunter's watchlist.json
|
|
// exactly — see the package comment before renaming anything.
|
|
type Entry struct {
|
|
Callsign string `json:"callsign"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
LastSeenStr string `json:"lastSeenStr"`
|
|
AddedAt time.Time `json:"addedAt"`
|
|
SpotCount int `json:"spotCount"`
|
|
IsContest bool `json:"isContest"`
|
|
Notify bool `json:"notify"`
|
|
// ClubLog expedition enrichment — phase 2. Carried so a DXHunter file
|
|
// round-trips; not filled by OpsLog yet.
|
|
IsExpedition bool `json:"isExpedition"`
|
|
ClubLogQSOs24h int `json:"clubLogQSOs24h"`
|
|
ClubLogTotalQSOs int `json:"clubLogTotalQSOs"`
|
|
ClubLogHasOQRS bool `json:"clubLogHasOQRS"`
|
|
ClubLogLiveStream bool `json:"clubLogLiveStream"`
|
|
ClubLogUpdatedAt time.Time `json:"clubLogUpdatedAt,omitempty"`
|
|
}
|
|
|
|
// Store owns the file. All methods are safe for concurrent use; writes are
|
|
// debounced so a burst of MarkSeen calls costs one disk write.
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
entries map[string]*Entry
|
|
path string
|
|
|
|
saveMu sync.Mutex
|
|
saveTimer *time.Timer
|
|
ioMu sync.Mutex
|
|
}
|
|
|
|
func New(path string) *Store {
|
|
s := &Store{entries: map[string]*Entry{}, path: path}
|
|
s.load()
|
|
return s
|
|
}
|
|
|
|
func (s *Store) load() {
|
|
data, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
return // absent on first run — created on first save
|
|
}
|
|
var list []Entry
|
|
if err := json.Unmarshal(data, &list); err != nil {
|
|
return // a corrupt file must not take the app down; it is left for repair
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range list {
|
|
e := list[i]
|
|
e.Callsign = strings.ToUpper(strings.TrimSpace(e.Callsign))
|
|
if e.Callsign == "" {
|
|
continue
|
|
}
|
|
s.entries[e.Callsign] = &e
|
|
}
|
|
}
|
|
|
|
// Entries returns a snapshot, watched-first ordering left to the UI.
|
|
func (s *Store) Entries() []Entry {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]Entry, 0, len(s.entries))
|
|
for _, e := range s.entries {
|
|
c := *e
|
|
c.LastSeenStr = lastSeenLabel(c.LastSeen)
|
|
out = append(out, c)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Callsign < out[j].Callsign })
|
|
return out
|
|
}
|
|
|
|
// Add creates an entry; contest marks it as re-workable every UTC day.
|
|
func (s *Store) Add(callsign string, contest bool) error {
|
|
call := strings.ToUpper(strings.TrimSpace(callsign))
|
|
if call == "" {
|
|
return fmt.Errorf("callsign required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.entries[call]; ok {
|
|
return fmt.Errorf("%s is already on the watchlist", call)
|
|
}
|
|
s.entries[call] = &Entry{Callsign: call, AddedAt: time.Now(), IsContest: contest}
|
|
s.scheduleSave()
|
|
return nil
|
|
}
|
|
|
|
// Remove deletes an entry.
|
|
func (s *Store) Remove(callsign string) error {
|
|
call := strings.ToUpper(strings.TrimSpace(callsign))
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.entries[call]; !ok {
|
|
return fmt.Errorf("%s is not on the watchlist", call)
|
|
}
|
|
delete(s.entries, call)
|
|
s.scheduleSave()
|
|
return nil
|
|
}
|
|
|
|
// SetNotify arms or disarms the alert for one entry.
|
|
func (s *Store) SetNotify(callsign string, on bool) error {
|
|
return s.patch(callsign, func(e *Entry) { e.Notify = on })
|
|
}
|
|
|
|
// SetContest flips the per-entry contest rule.
|
|
func (s *Store) SetContest(callsign string, on bool) error {
|
|
return s.patch(callsign, func(e *Entry) { e.IsContest = on })
|
|
}
|
|
|
|
func (s *Store) patch(callsign string, fn func(*Entry)) error {
|
|
call := strings.ToUpper(strings.TrimSpace(callsign))
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
e, ok := s.entries[call]
|
|
if !ok {
|
|
return fmt.Errorf("%s is not on the watchlist", call)
|
|
}
|
|
fn(e)
|
|
s.scheduleSave()
|
|
return nil
|
|
}
|
|
|
|
// Match returns the entry a spotted callsign belongs to, or "".
|
|
//
|
|
// Prefix match, exactly as DXHunter does it: an entry RI0SP must catch
|
|
// RI0SP/MM and RI0SP/P — expeditions sign portable more often than not, and an
|
|
// exact-only match left lastSeen stale while fresh spots scrolled past.
|
|
func (s *Store) Match(callsign string) (string, bool) {
|
|
call := strings.ToUpper(strings.TrimSpace(callsign))
|
|
if call == "" {
|
|
return "", false
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for pattern := range s.entries {
|
|
if call == pattern || strings.HasPrefix(call, pattern) {
|
|
return pattern, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// MarkSeen records a spot against the matching entry and reports whether the
|
|
// entry wants an alert.
|
|
func (s *Store) MarkSeen(callsign string) (entry string, notify bool, ok bool) {
|
|
pattern, found := s.Match(callsign)
|
|
if !found {
|
|
return "", false, false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
e := s.entries[pattern]
|
|
if e == nil {
|
|
return "", false, false
|
|
}
|
|
e.LastSeen = time.Now()
|
|
e.SpotCount++
|
|
s.scheduleSave()
|
|
return e.Callsign, e.Notify, true
|
|
}
|
|
|
|
// scheduleSave debounces the write: a burst of spots costs one file write two
|
|
// seconds after the last of them. Callers hold s.mu.
|
|
func (s *Store) scheduleSave() {
|
|
s.saveMu.Lock()
|
|
defer s.saveMu.Unlock()
|
|
if s.saveTimer != nil {
|
|
s.saveTimer.Stop()
|
|
}
|
|
s.saveTimer = time.AfterFunc(2*time.Second, func() { s.persist() })
|
|
}
|
|
|
|
func (s *Store) persist() {
|
|
list := s.Entries()
|
|
data, err := json.MarshalIndent(list, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.ioMu.Lock()
|
|
defer s.ioMu.Unlock()
|
|
_ = os.WriteFile(s.path, data, 0o644)
|
|
}
|
|
|
|
// Flush writes now — for shutdown, where the two-second debounce would lose
|
|
// the last edits.
|
|
func (s *Store) Flush() { s.persist() }
|
|
|
|
// lastSeenLabel is DXHunter's "Just now / 5m ago / 3h ago / 2d ago" string,
|
|
// computed at read time so it never goes stale in the file.
|
|
func lastSeenLabel(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "Never"
|
|
}
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "Just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
case d < 24*time.Hour:
|
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
|
}
|
|
}
|
|
|
|
// Get returns one entry by its exact name — the alert path needs its contest
|
|
// flag after MarkSeen named it.
|
|
func (s *Store) Get(callsign string) (Entry, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
e, ok := s.entries[strings.ToUpper(strings.TrimSpace(callsign))]
|
|
if !ok {
|
|
return Entry{}, false
|
|
}
|
|
return *e, true
|
|
}
|