From 4e28098a4eb4b8fb49b2f227950a125d9c1bdb99 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 30 Aug 2026 21:02:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(chase):=20the=20global=20hunt=20=E2=80=94?= =?UTF-8?q?=20new,=20or=20new=20plus=20unconfirmed,=20every=20category?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 135 ++++++++++++++++++++-- changelog.json | 6 +- frontend/src/App.tsx | 10 +- frontend/src/components/ClusterGrid.tsx | 11 +- frontend/src/components/DecodesPanel.tsx | 13 ++- frontend/src/components/SettingsModal.tsx | 46 ++++++-- frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 ++ frontend/wailsjs/go/models.ts | 22 ++++ internal/qso/qso.go | 70 ++++++++++- 10 files changed, 292 insertions(+), 33 deletions(-) diff --git a/app.go b/app.go index 9e77fc8..e9b902c 100644 --- a/app.go +++ b/app.go @@ -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 } diff --git a/changelog.json b/changelog.json index cd4465a..8d0088f 100644 --- a/changelog.json +++ b/changelog.json @@ -4,11 +4,13 @@ "date": "", "en": [ "FT decodes: a State column between the locator and the country — the two-letter badge plus the full name — for the WAS chasers.", - "FT decodes: a NEW STATE badge and filter — a US state never worked lights up in the status column, and the filter chip shows only those. The WAS chase is complete." + "FT decodes: a NEW STATE badge and filter — a US state never worked lights up in the status column, and the filter chip shows only those. The WAS chase is complete.", + "The hunt goes global: a “Chase” setting (DX Cluster page) decides for EVERY category — DXCC, band, mode, slot, prefix, county, state, grid — whether “worked but never confirmed” still counts as something to chase, judged against the confirmation sources you pick (LoTW, QSL card, eQSL, QRZ.com). Such needs show as dimmed badges: a QSL to chase, not a QSO to make. The grid’s own Chase selector folds into it." ], "fr": [ "FT decodes : une colonne État entre le locator et le pays — le badge deux lettres plus le nom complet — pour les chasseurs de WAS.", - "FT decodes : un badge et un filtre NOUVEL ÉTAT — un état US jamais contacté s’allume dans la colonne statut, et la puce de filtre ne montre que ceux-là. La chasse WAS est complète." + "FT decodes : un badge et un filtre NOUVEL ÉTAT — un état US jamais contacté s’allume dans la colonne statut, et la puce de filtre ne montre que ceux-là. La chasse WAS est complète.", + "La chasse devient globale : un réglage « Chasse » (page DX Cluster) décide pour TOUTES les catégories — DXCC, bande, mode, slot, préfixe, comté, état, grille — si « contacté mais jamais confirmé » reste à chasser, jugé selon les sources de confirmation choisies (LoTW, carte QSL, eQSL, QRZ.com). Ces besoins s’affichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67ff032..8d08514 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2088,7 +2088,7 @@ export default function App() { // worked_slot must be carried explicitly like every other field: this map is // assembled field by field, so a backend flag that nobody copies here simply // never reaches the panels — silently, since the extra key is just dropped. - const [spotStatus, setSpotStatus] = useState>({}); + const [spotStatus, setSpotStatus] = useState>({}); // Live mirror of spotStatus so the incoming-spot buffer can tell which slots // still need resolving without re-subscribing the cluster:spot listener. const spotStatusRef = useRef(spotStatus); @@ -3640,6 +3640,10 @@ export default function App() { worked_slot: !!(r as any).worked_slot, new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state, new_state: !!(r as any).new_state, + unconf_status: !!(r as any).unconf_status, + unconf_pfx: !!(r as any).unconf_pfx, + unconf_cty: !!(r as any).unconf_cty, + unconf_state: !!(r as any).unconf_state, new_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx, @@ -3747,6 +3751,10 @@ export default function App() { grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state, new_state: !!(r as any).new_state, + unconf_status: !!(r as any).unconf_status, + unconf_pfx: !!(r as any).unconf_pfx, + unconf_cty: !!(r as any).unconf_cty, + unconf_state: !!(r as any).unconf_state, new_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx, }; diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index 56d9490..aa3965b 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -65,6 +65,9 @@ export type SpotStatusEntry = { state?: string; new_pota?: boolean; new_pfx?: boolean; + unconf_status?: boolean; + unconf_pfx?: boolean; + unconf_cty?: boolean; pfx?: string; // lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded. lotw?: boolean; @@ -304,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ : s?.status === 'new-slot' ? t('clg2.newSlot') : s?.status === 'new-call' ? t('clg2.newCall') : t('clg2.wkdCall'); - parts.push({ text: label, color: main }); + // Dimmed when the need is only a missing confirmation — the grid's + // own convention, now spoken by every category. + parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { text: label, color: main }); } // Colours from lib/spotMarkers — shared with the band map so a marker is // never one colour here and another there. - if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') }); + if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty }); if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') }); - if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') }); + if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx }); // Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue // held back, so it reads as "less" of the same thing rather than a // different fact — and the label says which. diff --git a/frontend/src/components/DecodesPanel.tsx b/frontend/src/components/DecodesPanel.tsx index cbd41a6..cb72a01 100644 --- a/frontend/src/components/DecodesPanel.tsx +++ b/frontend/src/components/DecodesPanel.tsx @@ -73,6 +73,10 @@ type StatusEntry = { new_pfx?: boolean; new_grid?: boolean; new_state?: boolean; + unconf_status?: boolean; + unconf_pfx?: boolean; + unconf_cty?: boolean; + unconf_state?: boolean; // "new" = never worked, "unconf" = worked and awaiting a confirmation. grid_state?: string; state?: string; @@ -1145,7 +1149,9 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o )} {entities.map((b) => ( - + {t(b.label)} ))} @@ -1155,7 +1161,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o // Same hue, held back — the badge reads as "less" of the // same thing, and its label says which. One badge for both // is what made a station worked an hour ago read as new. - const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf'; + const unconf = (b.key === 'new_grid' && e?.grid_state === 'unconf') + || (b.key === 'new_state' && !!e?.unconf_state) + || (b.key === 'new_county' && !!e?.unconf_cty) + || (b.key === 'new_pfx' && !!e?.unconf_pfx); const c = markerColour(b.marker); return ( ({ mode: 'new', sources: ['lotw', 'card', 'eqsl'] }); + useEffect(() => { GetChaseSettings().then((c: any) => setChaseCfg({ mode: c?.mode ?? 'new', sources: c?.sources ?? ['lotw', 'card', 'eqsl'] })).catch(() => {}); }, []); const [dvkMsgs, setDvkMsgs] = useState([]); const [dvkStat, setDvkStat] = useState({ recording: false, playing: false, rec_slot: 0 }); const [dvkErr, setDvkErr] = useState(''); @@ -5459,19 +5462,42 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged -
- {t('gsc.hunt')} - -
+ + {/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix, + county, state, grid) judged as new-only or new-plus-unconfirmed, + against the confirmation sources the operator trusts. */} +
+
+ {t('chg.mode')} + +
+ {chaseCfg.mode === 'new_unconfirmed' && ( +
+ {t('chg.sources')} + {([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => ( + + ))} +
+ )} +
+ {/* Chase new — its own option, NOT nested under grid chasing. Chasing squares and chasing entities are different wants; they only share the PSK Reporter feed, which either one brings up. */} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 4594b6f..e651b45 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -452,6 +452,8 @@ export function GetChaseNewGrids():Promise; export function GetChaseNewSpots():Promise>; +export function GetChaseSettings():Promise; + export function GetChatHistory(arg1:number):Promise>; export function GetClublogCtyInfo():Promise; @@ -1028,6 +1030,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise; export function SaveCabrilloFile():Promise; +export function SaveChaseSettings(arg1:main.ChaseSettings):Promise; + export function SaveClusterServer(arg1:cluster.ServerConfig):Promise; export function SaveEmailSettings(arg1:main.EmailSettings):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index edab00d..8295b0d 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -842,6 +842,10 @@ export function GetChaseNewSpots() { return window['go']['main']['App']['GetChaseNewSpots'](); } +export function GetChaseSettings() { + return window['go']['main']['App']['GetChaseSettings'](); +} + export function GetChatHistory(arg1) { return window['go']['main']['App']['GetChatHistory'](arg1); } @@ -1994,6 +1998,10 @@ export function SaveCabrilloFile() { return window['go']['main']['App']['SaveCabrilloFile'](); } +export function SaveChaseSettings(arg1) { + return window['go']['main']['App']['SaveChaseSettings'](arg1); +} + export function SaveClusterServer(arg1) { return window['go']['main']['App']['SaveClusterServer'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index fc344e5..3213bbf 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2430,6 +2430,20 @@ export namespace main { this.at = source["at"]; } } + export class ChaseSettings { + mode: string; + sources: string[]; + + static createFrom(source: any = {}) { + return new ChaseSettings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.mode = source["mode"]; + this.sources = source["sources"]; + } + } export class ChatMessage { id: number; operator: string; @@ -3958,6 +3972,10 @@ export namespace main { county?: string; state?: string; new_state: boolean; + unconf_status?: boolean; + unconf_pfx?: boolean; + unconf_cty?: boolean; + unconf_state?: boolean; new_pota: boolean; grid?: string; new_grid: boolean; @@ -3985,6 +4003,10 @@ export namespace main { this.county = source["county"]; this.state = source["state"]; this.new_state = source["new_state"]; + this.unconf_status = source["unconf_status"]; + this.unconf_pfx = source["unconf_pfx"]; + this.unconf_cty = source["unconf_cty"]; + this.unconf_state = source["unconf_state"]; this.new_pota = source["new_pota"]; this.grid = source["grid"]; this.new_grid = source["new_grid"]; diff --git a/internal/qso/qso.go b/internal/qso/qso.go index ea14ec9..c78ffc3 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -2657,10 +2657,20 @@ type EntitySlot struct { // Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into // one bucket. Callers must normalise their lookup mode the same way. func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) { + return r.EntitySlotMapPred(ctx, keyFor, normMode, "") +} + +// EntitySlotMapPred is EntitySlotMap over only the QSOs matching pred — the +// confirmed-only ledger the "chase unconfirmed too" mode judges against. +func (r *Repo) EntitySlotMapPred(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string, pred string) (map[int]*EntitySlot, error) { + where := "" + if pred != "" { + where = " AND " + pred + } rows, err := r.db.QueryContext(ctx, `SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso WHERE band IS NOT NULL AND band != '' - AND mode IS NOT NULL AND mode != ''`) + AND mode IS NOT NULL AND mode != ''`+where) if err != nil { return nil, err } @@ -2710,8 +2720,18 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store // One pass, used by the cluster spot colouring to flag "already worked this // exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight. func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) { + return r.WorkedCallsignsPred(ctx, "") +} + +// WorkedCallsignsPred restricts the callsign ledger to QSOs matching pred — +// the confirmed-prefix (WPX) chase reads its prefixes off this set. +func (r *Repo) WorkedCallsignsPred(ctx context.Context, pred string) (map[string]struct{}, error) { + where := "" + if pred != "" { + where = " AND " + pred + } rows, err := r.db.QueryContext(ctx, - `SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`) + `SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`+where) if err != nil { return nil, err } @@ -2732,9 +2752,18 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) // the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county // are considered. Empty keys (unresolvable state/county) are skipped. func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) { + return r.WorkedCountyKeysPred(ctx, keyFn, "") +} + +// WorkedCountyKeysPred restricts the county ledger to QSOs matching pred. +func (r *Repo) WorkedCountyKeysPred(ctx context.Context, keyFn func(state, cnty string) string, pred string) (map[string]struct{}, error) { + where := "" + if pred != "" { + where = " AND " + pred + } rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso - WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`) + WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`+where) if err != nil { return nil, err } @@ -2755,9 +2784,18 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri // WorkedStateKeys returns the set of US states already worked, uppercased — // the WAS ledger the cluster and decode panels judge NEW STATE against. func (r *Repo) WorkedStateKeys(ctx context.Context) (map[string]struct{}, error) { + return r.WorkedStateKeysPred(ctx, "") +} + +// WorkedStateKeysPred restricts the state ledger to QSOs matching pred. +func (r *Repo) WorkedStateKeysPred(ctx context.Context, pred string) (map[string]struct{}, error) { + where := "" + if pred != "" { + where = " AND " + pred + } rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT UPPER(COALESCE(state,'')) FROM qso - WHERE dxcc IN (291,110,6) AND state IS NOT NULL AND state != ''`) + WHERE dxcc IN (291,110,6) AND state IS NOT NULL AND state != ''`+where) if err != nil { return nil, err } @@ -3385,6 +3423,30 @@ type SlotStats struct { // only way those two agree. const ConfirmedValues = "('Y','V')" +// ConfirmSourcesPredicate builds the SQL that says "this QSO is confirmed", +// from the operator's chosen sources — the same choice the award engine's +// fixed three (LoTW, card, eQSL) used to hard-code. QRZ.com's confirmation is +// its download status; HRDLog has no confirmation field in ADIF at all, which +// is why it cannot be offered. Empty input falls back to the classic three. +func ConfirmSourcesPredicate(sources []string) string { + cols := map[string]string{ + "lotw": "lotw_rcvd", + "card": "qsl_rcvd", + "eqsl": "eqsl_rcvd", + "qrz": "qrzcom_qso_download_status", + } + var parts []string + for _, src := range sources { + if col, ok := cols[strings.ToLower(strings.TrimSpace(src))]; ok { + parts = append(parts, col+" IN "+ConfirmedValues) + } + } + if len(parts) == 0 { + return "(lotw_rcvd IN " + ConfirmedValues + " OR qsl_rcvd IN " + ConfirmedValues + " OR eqsl_rcvd IN " + ConfirmedValues + ")" + } + return "(" + strings.Join(parts, " OR ") + ")" +} + // GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass. // "Confirmed" = LoTW or paper QSL received (the award-valid sources). func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {