feat(chase): the global hunt — new, or new plus unconfirmed, every category
The gap between worked and confirmed becomes visible everywhere. A Chase setting on the DX Cluster page picks the mode; in new-plus-unconfirmed the verdicts for DXCC/band/mode/slot, prefix, county and state are judged against CONFIRMED-only ledgers built from the operator's chosen sources (LoTW, card, eQSL, QRZ.com — HRDLog has no confirmation field in ADIF to offer), and a need that exists only because a QSL never arrived is flagged Unconf: the cluster and decode badges render it dimmed, the convention the grid hunt introduced. The grid's own Chase selector folds into the global one; its square-matching scope stays where it was, being grid-specific. One snapshot key change rebuilds the cache when any of it flips.
This commit is contained in:
@@ -159,6 +159,8 @@ const (
|
||||
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
|
||||
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
|
||||
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
|
||||
keyChaseMode = "chase.mode" // "new" | "new_unconfirmed" — the global hunt, every category
|
||||
keyChaseConfirm = "chase.confirm" // CSV of confirmation sources: lotw,card,eqsl,qrz
|
||||
keyAudioMonitorOn = "audio.monitor.on" // play the network RX audio through the Listening device (the stream itself stays open for the recorder either way)
|
||||
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
||||
@@ -19824,7 +19826,14 @@ type SpotStatus struct {
|
||||
// NewState says this US state has never been worked — the WAS gap the
|
||||
// decode panel's filter chases. Orthogonal to Status, like NewCounty.
|
||||
NewState bool `json:"new_state"`
|
||||
NewPOTA bool `json:"new_pota"`
|
||||
// The Unconf flags say the matching need exists ONLY because the contact
|
||||
// was never confirmed (under the operator's chosen QSL sources): a QSL to
|
||||
// chase, not a QSO to make. The badges render them dashed, the grid way.
|
||||
UnconfStatus bool `json:"unconf_status,omitempty"`
|
||||
UnconfPfx bool `json:"unconf_pfx,omitempty"`
|
||||
UnconfCty bool `json:"unconf_cty,omitempty"`
|
||||
UnconfState bool `json:"unconf_state,omitempty"`
|
||||
NewPOTA bool `json:"new_pota"`
|
||||
// Grid is the 4-character square this station announced in a CQ on the UDP
|
||||
// link, and NewGrid says that square has never been worked. Both are empty /
|
||||
// false for any station this receiver has not decoded — a DX-cluster line
|
||||
@@ -19873,6 +19882,16 @@ type clusterStatusCache struct {
|
||||
workedCallSlotsDig map[string]struct{} // same set, digital modes folded to DIG // nil unless the "same slot" option is on
|
||||
workedCounties map[string]struct{}
|
||||
workedStates map[string]struct{}
|
||||
// The CONFIRMED-only ledgers, built when the global hunt is
|
||||
// "new + unconfirmed": verdicts are judged against these, and a need that
|
||||
// exists only because a contact was never confirmed is flagged Unconf so
|
||||
// its badge can say "a QSL to chase", not "a QSO to make".
|
||||
chaseUnconf bool
|
||||
chaseKey string
|
||||
entitiesConf map[int]*qso.EntitySlot
|
||||
workedPfxConf map[string]struct{}
|
||||
workedCountiesConf map[string]struct{}
|
||||
workedStatesConf map[string]struct{}
|
||||
// callCounties holds callsign → "STATE,County" for stations already logged
|
||||
// with a county, so a spot shows the county the entry panel showed rather
|
||||
// than the one derived from the licence ZIP. See qso.CallCounties.
|
||||
@@ -19893,6 +19912,44 @@ type clusterStatusCache struct {
|
||||
slotHighlight bool // (same: the slot index is built for either)
|
||||
}
|
||||
|
||||
// ChaseSettings is the global hunt: does "worked but never confirmed" still
|
||||
// count as something to chase, and which QSL systems count as confirmation.
|
||||
type ChaseSettings struct {
|
||||
Mode string `json:"mode"` // "new" | "new_unconfirmed"
|
||||
Sources []string `json:"sources"` // subset of lotw,card,eqsl,qrz
|
||||
}
|
||||
|
||||
// GetChaseSettings returns the global hunt settings.
|
||||
func (a *App) GetChaseSettings() ChaseSettings {
|
||||
mode := a.settingOr(keyChaseMode, "new")
|
||||
if mode != "new_unconfirmed" {
|
||||
mode = "new"
|
||||
}
|
||||
csv := a.settingOr(keyChaseConfirm, "lotw,card,eqsl")
|
||||
var src []string
|
||||
for _, part := range strings.Split(csv, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
src = append(src, part)
|
||||
}
|
||||
}
|
||||
return ChaseSettings{Mode: mode, Sources: src}
|
||||
}
|
||||
|
||||
// SaveChaseSettings stores them and drops the status snapshot so the next
|
||||
// batch is judged under the new rules.
|
||||
func (a *App) SaveChaseSettings(cs ChaseSettings) error {
|
||||
mode := cs.Mode
|
||||
if mode != "new_unconfirmed" {
|
||||
mode = "new"
|
||||
}
|
||||
a.setSetting(keyChaseMode, mode)
|
||||
a.setSetting(keyChaseConfirm, strings.Join(cs.Sources, ","))
|
||||
a.clusterStatusMu.Lock()
|
||||
a.clusterStatusIdx = nil
|
||||
a.clusterStatusMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clusterStatusMaps returns the cached worked-index snapshot, building it once
|
||||
// per logbook change (invalidated by invalidateAwardStats) or when a setting
|
||||
// that shapes the maps flips. This turns the per-batch full-logbook scans into
|
||||
@@ -19905,16 +19962,23 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
||||
// holding the status mutex across that is how two subsystems deadlock.
|
||||
gs := a.GetGridScopeSettings()
|
||||
gridScopeNow := lookupGridScope(gs.Scope)
|
||||
gridHuntNow := gs.Hunt
|
||||
// The grid hunt FOLLOWS the global chase mode now — the per-grid selector
|
||||
// grew into the global option, and two switches for one idea is one too
|
||||
// many. (Same vocabulary: "new" / "new_unconfirmed".)
|
||||
chase := a.GetChaseSettings()
|
||||
gridHuntNow := chase.Mode
|
||||
chaseKeyNow := chase.Mode + "|" + strings.Join(chase.Sources, ",")
|
||||
a.clusterStatusMu.Lock()
|
||||
defer a.clusterStatusMu.Unlock()
|
||||
if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot &&
|
||||
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow {
|
||||
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow &&
|
||||
c.chaseKey == chaseKeyNow {
|
||||
return c
|
||||
}
|
||||
c := &clusterStatusCache{
|
||||
groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight,
|
||||
gridScope: gridScopeNow, gridHunt: gridHuntNow,
|
||||
chaseUnconf: chase.Mode == "new_unconfirmed", chaseKey: chaseKeyNow,
|
||||
}
|
||||
if a.qso == nil {
|
||||
a.clusterStatusIdx = c
|
||||
@@ -19982,6 +20046,19 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
||||
// extra query. Derived rather than read from the stored PFX column: that
|
||||
// column is only filled when an import supplied it, and deriving keeps this
|
||||
// in step with the WPX award, which does the same thing.
|
||||
if c.chaseUnconf {
|
||||
pred := qso.ConfirmSourcesPredicate(chase.Sources)
|
||||
c.entitiesConf, _ = a.qso.EntitySlotMapPred(a.ctx, keyFor, c.normMode, pred)
|
||||
c.workedCountiesConf, _ = a.qso.WorkedCountyKeysPred(a.ctx, award.USCountyKey, pred)
|
||||
c.workedStatesConf, _ = a.qso.WorkedStateKeysPred(a.ctx, pred)
|
||||
confCalls, _ := a.qso.WorkedCallsignsPred(a.ctx, pred)
|
||||
c.workedPfxConf = make(map[string]struct{}, len(confCalls))
|
||||
for call := range confCalls {
|
||||
if pfx := award.WPXPrefix(call); pfx != "" {
|
||||
c.workedPfxConf[pfx] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.workedPfx = make(map[string]struct{}, len(c.workedCalls))
|
||||
for call := range c.workedCalls {
|
||||
if p := award.WPXPrefix(call); p != "" {
|
||||
@@ -20307,6 +20384,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
workedCounties := idx.workedCounties
|
||||
workedPOTA := idx.workedPOTA
|
||||
workedPfx := idx.workedPfx
|
||||
// The hunt decides the ledger: "new + unconfirmed" judges every category
|
||||
// against the CONFIRMED sets, and the all-QSO sets then say whether a need
|
||||
// is a fresh one or a missing QSL (the Unconf flags).
|
||||
judgeEntities, judgePfx, judgeCounties, judgeStates := entities, workedPfx, workedCounties, idx.workedStates
|
||||
if idx.chaseUnconf {
|
||||
judgeEntities, judgePfx, judgeCounties, judgeStates = idx.entitiesConf, idx.workedPfxConf, idx.workedCountiesConf, idx.workedStatesConf
|
||||
}
|
||||
normMode := idx.normMode
|
||||
sameSlot := idx.sameSlot
|
||||
for i, q := range spots {
|
||||
@@ -20350,8 +20434,11 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
// NEW PFX: the spot's CQ WPX prefix, never worked before.
|
||||
if p := award.WPXPrefix(q.Call); p != "" {
|
||||
out[i].Pfx = p
|
||||
if _, done := workedPfx[p]; !done {
|
||||
if _, done := judgePfx[p]; !done {
|
||||
out[i].NewPfx = true
|
||||
if _, everWorked := workedPfx[p]; everWorked {
|
||||
out[i].UnconfPfx = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// NEW POTA: the spot's tagged park, never worked before.
|
||||
@@ -20415,28 +20502,38 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
st, name, _ := strings.Cut(cnty, ",")
|
||||
out[i].State, out[i].County = st, name
|
||||
if st != "" {
|
||||
if _, done := idx.workedStates[strings.ToUpper(st)]; !done {
|
||||
if _, done := judgeStates[strings.ToUpper(st)]; !done {
|
||||
out[i].NewState = true
|
||||
if _, ever := idx.workedStates[strings.ToUpper(st)]; ever {
|
||||
out[i].UnconfState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Logged means worked, so this can never be a new county — but say
|
||||
// so through the same key the flag below uses, not by assumption.
|
||||
if key := award.USCountyKey(st, name); key != "" {
|
||||
if _, done := workedCounties[key]; !done {
|
||||
if _, done := judgeCounties[key]; !done {
|
||||
out[i].NewCounty = true
|
||||
if _, ever := workedCounties[key]; ever {
|
||||
out[i].UnconfCty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if a.uls != nil {
|
||||
if loc, ok := a.uls.Resolve(q.Call); ok {
|
||||
out[i].County, out[i].State = loc.County, loc.State
|
||||
if loc.State != "" {
|
||||
if _, done := idx.workedStates[strings.ToUpper(loc.State)]; !done {
|
||||
if _, done := judgeStates[strings.ToUpper(loc.State)]; !done {
|
||||
out[i].NewState = true
|
||||
if _, ever := idx.workedStates[strings.ToUpper(loc.State)]; ever {
|
||||
out[i].UnconfState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if key := award.USCountyKey(loc.State, loc.County); key != "" {
|
||||
if _, done := workedCounties[key]; !done {
|
||||
if _, done := judgeCounties[key]; !done {
|
||||
out[i].NewCounty = true
|
||||
if _, ever := workedCounties[key]; ever {
|
||||
out[i].UnconfCty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20464,9 +20561,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
if dxccNum == 0 {
|
||||
continue // can't resolve the spot's entity number → don't guess
|
||||
}
|
||||
e, worked := entities[dxccNum]
|
||||
e, worked := judgeEntities[dxccNum]
|
||||
if !worked {
|
||||
out[i].Status = "new"
|
||||
if _, ever := entities[dxccNum]; ever {
|
||||
out[i].UnconfStatus = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
// The check mode goes through the same normaliser as the slot map
|
||||
@@ -20481,6 +20581,19 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
|
||||
_, haveSlot := e.Slots[out[i].Band][checkMode]
|
||||
out[i].Status = spotEntityStatus(true, haveBand, haveMode, haveSlot, out[i].Mode)
|
||||
// A need judged against the confirmed ledger that the all-QSO ledger
|
||||
// says was already worked is a missing QSL, and its badge should read
|
||||
// as one. Judged at the SAME grain as the status itself.
|
||||
if idx.chaseUnconf && out[i].Status != "" {
|
||||
if eAll, ok := entities[dxccNum]; ok {
|
||||
_, bAll := eAll.Bands[out[i].Band]
|
||||
_, mAll := eAll.Modes[checkMode]
|
||||
_, sAll := eAll.Slots[out[i].Band][checkMode]
|
||||
if spotEntityStatus(true, bAll, mAll, sAll, out[i].Mode) == "" {
|
||||
out[i].UnconfStatus = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user