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
|
||||
}
|
||||
|
||||
+4
-2
@@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (F1–F6).
|
||||
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. */}
|
||||
|
||||
Vendored
+4
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user