feat(dxped): the announced DX, judged against your own log
A DXpeditions tab holding the two feeds the DX world announces itself on: NG3K's ADXO (structured — dates, entity, calls, bands, modes, QSL route) and DX-World's headlines. What a logger can say that a news reader cannot is whether the operation is worth chasing, so every announcement is put through the SAME verdict the cluster paints on a spot — one badge, strongest wins, dimmed when the need is only a missing QSL — with an 'only what I need' filter. One click watches every callsign of an operation; the news headlines are mined for callsigns so they can be watched the same way. Parsers ported from DXHunter's and pinned with table tests against real feed text: the 'as PJ2/W2APF' mining, the DXCC-prefix-first normalisation without which no spot ever matches, both ADXO date forms, and the '160-6m' span whose unit is written once (DXHunter read that as 6m alone and lost the low end). Watchlist membership now announces itself app-wide, on the same card as a new version: the bindings emit watchlist:changed, so a call added from the cluster or this tab is confirmed where the operator is actually looking — the panel's own inline message never was.
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/dxped"
|
||||
)
|
||||
|
||||
// ── DXpeditions (ADXO announcements + DX-World news) ────────────────────
|
||||
//
|
||||
// What a logger can say that a news reader cannot: whether the announced
|
||||
// operation is worth chasing. Every activation is judged against THIS log —
|
||||
// the same verdict the cluster paints on a spot — so the list reads as "what I
|
||||
// still need", not "what is on the air".
|
||||
|
||||
// DXpedition is one announced operation plus what it is worth here.
|
||||
type DXpedition struct {
|
||||
dxped.Activation
|
||||
// Status is the strongest verdict across the announced callsigns, bands and
|
||||
// modes: "new" (entity never worked) beats "new-band-mode", which beats
|
||||
// "new-band", "new-mode", "new-slot", and finally "worked". Empty when the
|
||||
// callsign resolves to no entity — a prefix ADXO knows and cty.dat does not.
|
||||
Status string `json:"status_chase"`
|
||||
// Unconfirmed marks a need that is only a missing QSL, so the badge can be
|
||||
// drawn dimmed exactly as it is in the cluster and the decode list.
|
||||
Unconfirmed bool `json:"unconfirmed"`
|
||||
}
|
||||
|
||||
// chaseRank orders the verdicts from "most worth chasing" down. The DXpedition
|
||||
// list shows ONE badge, so the ranking is the whole decision.
|
||||
var chaseRank = map[string]int{
|
||||
"new": 6,
|
||||
"new-band-mode": 5,
|
||||
"new-band": 4,
|
||||
"new-mode": 3,
|
||||
"new-slot": 2,
|
||||
"worked": 1,
|
||||
}
|
||||
|
||||
// GetDXpeditions returns the announced operations, freshest feed permitting,
|
||||
// each carrying its chase verdict.
|
||||
func (a *App) GetDXpeditions() ([]DXpedition, error) {
|
||||
if a.dxped == nil {
|
||||
a.dxped = dxped.New()
|
||||
}
|
||||
acts, err := a.dxped.Activations(a.ctx)
|
||||
if err != nil {
|
||||
applog.Printf("dxped: adxo fetch: %v", err)
|
||||
if len(acts) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
// Stale data with a logged error beats an empty tab.
|
||||
}
|
||||
out := make([]DXpedition, 0, len(acts))
|
||||
for _, act := range acts {
|
||||
out = append(out, DXpedition{Activation: act, Status: "", Unconfirmed: false})
|
||||
}
|
||||
a.judgeDXpeditions(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// judgeDXpeditions fills in the chase verdict, in place.
|
||||
//
|
||||
// One ClusterSpotStatuses call for the whole list rather than one per row: the
|
||||
// worked-index it builds is the expensive part (a full pass over the log), and
|
||||
// asking it forty times to answer forty rows was the difference between a tab
|
||||
// that opens and a tab that stalls a remote MySQL for a second.
|
||||
func (a *App) judgeDXpeditions(list []DXpedition) {
|
||||
if a.qso == nil || len(list) == 0 {
|
||||
return
|
||||
}
|
||||
type slot struct{ row int }
|
||||
var queries []SpotQuery
|
||||
var owners []slot
|
||||
for i, d := range list {
|
||||
calls := d.Calls
|
||||
if len(calls) == 0 {
|
||||
if c := strings.ToUpper(strings.TrimSpace(d.Callsign)); c != "" {
|
||||
calls = []string{c}
|
||||
}
|
||||
}
|
||||
for _, call := range calls {
|
||||
// No announced band/mode: ask the entity-level question alone.
|
||||
if len(d.Bands) == 0 && len(d.Modes) == 0 {
|
||||
queries = append(queries, SpotQuery{Call: call})
|
||||
owners = append(owners, slot{i})
|
||||
continue
|
||||
}
|
||||
bands := d.Bands
|
||||
if len(bands) == 0 {
|
||||
bands = []string{""}
|
||||
}
|
||||
modes := d.Modes
|
||||
if len(modes) == 0 {
|
||||
modes = []string{""}
|
||||
}
|
||||
for _, b := range bands {
|
||||
for _, m := range modes {
|
||||
queries = append(queries, SpotQuery{Call: call, Band: b, Mode: m})
|
||||
owners = append(owners, slot{i})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(queries) == 0 {
|
||||
return
|
||||
}
|
||||
res := a.ClusterSpotStatuses(queries)
|
||||
for i, r := range res {
|
||||
if i >= len(owners) {
|
||||
break
|
||||
}
|
||||
row := owners[i].row
|
||||
if chaseRank[r.Status] > chaseRank[list[row].Status] {
|
||||
list[row].Status = r.Status
|
||||
list[row].Unconfirmed = r.UnconfStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
|
||||
// of each one so the reader can act on them.
|
||||
func (a *App) GetDXWorldNews() ([]dxped.News, error) {
|
||||
if a.dxped == nil {
|
||||
a.dxped = dxped.New()
|
||||
}
|
||||
news, err := a.dxped.News(a.ctx)
|
||||
if err != nil {
|
||||
applog.Printf("dxped: dx-world fetch: %v", err)
|
||||
if len(news) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return news, nil
|
||||
}
|
||||
|
||||
// RefreshDXpeditions drops both caches so the next read goes to the network.
|
||||
// Wired to the tab's refresh button: an operator who has just read of a landing
|
||||
// on a cluster should not wait out the cache to see it here.
|
||||
func (a *App) RefreshDXpeditions() {
|
||||
if a.dxped == nil {
|
||||
a.dxped = dxped.New()
|
||||
}
|
||||
a.dxped.Invalidate()
|
||||
}
|
||||
Reference in New Issue
Block a user