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:
2026-08-30 21:02:34 +02:00
parent 52e98a71f4
commit 4e28098a4e
10 changed files with 292 additions and 33 deletions
+124 -11
View File
@@ -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
}
+4 -2
View File
@@ -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 grids 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é sallume 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é sallume 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 saffichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans."
]
},
{
+9 -1
View File
@@ -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<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// 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,
};
+8 -3
View File
@@ -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.
+11 -2
View File
@@ -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
</span>
)}
{entities.map((b) => (
<span key={b.label} className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls)}>
<span key={b.label}
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls, e?.unconf_status && 'opacity-50')}>
{t(b.label)}
</span>
))}
@@ -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 (
<span key={b.key as string}
+36 -10
View File
@@ -10,6 +10,7 @@ import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
GetChaseSettings, SaveChaseSettings,
GetAudioMonitorPref,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
@@ -1718,6 +1719,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// DVK voice-keyer messages (F1F6).
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
const [chaseCfg, setChaseCfg] = useState<{ mode: string; sources: string[] }>({ 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<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const [dvkErr, setDvkErr] = useState('');
@@ -5459,19 +5462,42 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.hunt')}</span>
<Select value={gridScope.hunt} onValueChange={(v) => saveGridScope({ ...gridScope, hunt: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 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. */}
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
{chaseCfg.mode === 'new_unconfirmed' && (
<div className="flex items-center gap-4 flex-wrap pl-2">
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={chaseCfg.sources.includes(k)}
onCheckedChange={(c) => {
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
const next = { ...chaseCfg, sources };
setChaseCfg(next); void SaveChaseSettings(next as any);
}} />
{label}
</label>
))}
</div>
)}
</div>
{/* 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. */}
+4
View File
@@ -452,6 +452,8 @@ export function GetChaseNewGrids():Promise<boolean>;
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
export function GetChaseSettings():Promise<main.ChaseSettings>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
@@ -1028,6 +1030,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveCabrilloFile():Promise<string>;
export function SaveChaseSettings(arg1:main.ChaseSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
+8
View File
@@ -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);
}
+22
View File
@@ -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"];
+66 -4
View File
@@ -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) {