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 watch them. // // Deliberately NOT judged against the log. A headline names no band, so the // only verdict available is entity-level, and a pane of "NEW DXCC" and // "worked" badges turned out to say nothing an operator could act on — the // same two words on every row is noise wearing the clothes of information. // The announcements pane, which knows the bands and modes, is where a chase // verdict is worth drawing. 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() }