ctrl+wheel is the WINDOW zoom everywhere else in OpsLog, and one gesture that resizes the whole app over one panel and one band map over another is a gesture nobody can trust. The + and - buttons keep the zoom, and the footer hint stops advertising what is gone. The DX-World headlines now carry a chase badge like the announcements — but ENTITY-LEVEL only. A headline names no band, and asking the slot question without one answers 'new band' for every entity ever worked: the one mistake that costs a QSO. So the news says NEW DXCC or worked, and the sharper verdicts stay where the bands and modes are known.
206 lines
6.0 KiB
Go
206 lines
6.0 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// DXNews is one DX-World post plus what its callsigns are worth here.
|
|
type DXNews struct {
|
|
dxped.News
|
|
// Status is ENTITY-LEVEL only, and deliberately so: a headline announces no
|
|
// bands or modes, and asking the slot question without them would answer
|
|
// "new band" for every entity ever worked — the one mistake that costs a
|
|
// QSO. So it says "new" (entity never worked) or "worked", nothing finer;
|
|
// the announcements pane, which HAS the bands and modes, is where the
|
|
// sharper verdicts belong.
|
|
Status string `json:"status_chase"`
|
|
Unconfirmed bool `json:"unconfirmed"`
|
|
}
|
|
|
|
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
|
|
// of each one and judged against the log.
|
|
func (a *App) GetDXWorldNews() ([]DXNews, 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
|
|
}
|
|
}
|
|
out := make([]DXNews, 0, len(news))
|
|
for _, n := range news {
|
|
out = append(out, DXNews{News: n})
|
|
}
|
|
a.judgeNews(out)
|
|
return out, nil
|
|
}
|
|
|
|
// judgeNews fills the entity-level verdict for each headline.
|
|
//
|
|
// One query per callsign with NO band and NO mode: the only verdict that
|
|
// means anything in that state is "this entity has never been worked". Every
|
|
// finer answer the spot judge can give without a band is an artefact of the
|
|
// missing band, so it is flattened to "worked" rather than repeated.
|
|
func (a *App) judgeNews(list []DXNews) {
|
|
if a.qso == nil || len(list) == 0 {
|
|
return
|
|
}
|
|
var queries []SpotQuery
|
|
var owners []int
|
|
for i, n := range list {
|
|
for _, call := range n.Calls {
|
|
queries = append(queries, SpotQuery{Call: call})
|
|
owners = append(owners, i)
|
|
}
|
|
}
|
|
if len(queries) == 0 {
|
|
return
|
|
}
|
|
res := a.ClusterSpotStatuses(queries)
|
|
for i, r := range res {
|
|
if i >= len(owners) {
|
|
break
|
|
}
|
|
row := owners[i]
|
|
st := ""
|
|
switch {
|
|
case r.Status == "new":
|
|
st = "new"
|
|
case r.Status != "":
|
|
st = "worked"
|
|
}
|
|
if chaseRank[st] > chaseRank[list[row].Status] {
|
|
list[row].Status = st
|
|
list[row].Unconfirmed = r.UnconfStatus
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|