feat(worked): fold portable callsigns into the worked-before history
Typing RK3DWA found nothing while RK3DWA/3 found 21 QSOs, so a station's history was only visible if you happened to type the exact form it had been logged under — and an operator who worked it as /0, /P or /MM saw none of it. The other RDA tools and Log4OM fold these together; this does too. The predicate strips the suffix from what was typed and matches "call = base OR call LIKE base/%", so it works from either end: the base call finds the portable QSOs and a portable call finds the plain ones. Deliberately not a bare prefix LIKE 'RK3DWA%', which would also match RK3DWAB — a different station. The '/' is what makes it the same operator. Settings -> General to turn it off. Default ON, hence the inverted storage: an existing install has no key, and reading that as OFF would leave everyone with the behaviour we were asked to change. Contest dupe checking is untouched — it runs through ContestDupe, a separate binding, and stays an exact match as a contest requires.
This commit is contained in:
@@ -293,6 +293,12 @@ const (
|
||||
|
||||
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
||||
|
||||
// Worked-before: fold an operator's portable forms (X, X/3, X/P) together.
|
||||
// Stored inverted — "0" means OFF — so the feature is ON for an existing
|
||||
// install that has never seen the key, which is the behaviour operators asked
|
||||
// for. See GetWorkedCallVariants.
|
||||
keyWorkedCallVariants = "worked.call_variants"
|
||||
|
||||
keyBackupEnabled = "backup.enabled"
|
||||
keyBackupFolder = "backup.folder"
|
||||
keyBackupRotation = "backup.rotation"
|
||||
@@ -6091,6 +6097,31 @@ func (a *App) bulkSetFrequency(ids []int64, value string) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetWorkedCallVariants reports whether "worked before" folds an operator's
|
||||
// portable forms together (RK3DWA ↔ RK3DWA/3 ↔ RK3DWA/P).
|
||||
//
|
||||
// Defaults to ON, hence the inverted storage: an install that predates the
|
||||
// setting has no key at all, and reading that as OFF would leave every existing
|
||||
// operator with the old narrow behaviour they asked us to change.
|
||||
func (a *App) GetWorkedCallVariants() (bool, error) {
|
||||
if a.settings == nil {
|
||||
return true, fmt.Errorf("db not initialized")
|
||||
}
|
||||
v, err := a.settings.Get(a.ctx, keyWorkedCallVariants)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
return v != "0", nil
|
||||
}
|
||||
|
||||
// SetWorkedCallVariants persists the toggle.
|
||||
func (a *App) SetWorkedCallVariants(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyWorkedCallVariants, boolStr(on))
|
||||
}
|
||||
|
||||
// WorkedBefore returns prior contacts with the given callsign at both
|
||||
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
||||
// will infer it from past QSOs with the same call when possible.
|
||||
@@ -6107,7 +6138,8 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err
|
||||
dxccHint = dxcc.EntityDXCC(m.Entity.Name)
|
||||
}
|
||||
}
|
||||
wb, err := a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
|
||||
variants, _ := a.GetWorkedCallVariants()
|
||||
wb, err := a.qso.WorkedBefore(a.ctx, callsign, dxccHint, variants)
|
||||
// Attach the ClubLog Most Wanted rank for this entity (opt-in) so the entry
|
||||
// matrix can show it next to the country name.
|
||||
if err == nil && wb.DXCC > 0 && a.clublogMW != nil && a.clublogMostWantedEnabled() {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
[
|
||||
{
|
||||
"version": "0.24.0",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Worked before: an operator's portable callsigns now count as the same station. Typing RK3DWA finds the RK3DWA/3, /P and /MM contacts too, and the other way round — before, the history only appeared if you typed the exact form. Can be turned off in Settings → General; contest dupe checking is unaffected and stays exact."
|
||||
],
|
||||
"fr": [
|
||||
"Déjà contacté : les indicatifs portables d'un opérateur comptent désormais comme la même station. Taper RK3DWA retrouve aussi les QSO en RK3DWA/3, /P et /MM, et inversement — avant, l'historique n'apparaissait que si tu tapais la forme exacte. Désactivable dans Réglages → Général ; le contrôle de doublon en concours n'est pas touché et reste strict."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.9",
|
||||
"date": "",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AudioStartTX, AudioStopTX, AudioTXActive,
|
||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||
GetClusterAutoConnect, SetClusterAutoConnect, GetSelfSpotSettings, SaveSelfSpotSettings,
|
||||
GetWorkedCallVariants, SetWorkedCallVariants,
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
@@ -1524,6 +1525,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
|
||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
||||
// Defaults to true so the checkbox matches the backend before the read lands.
|
||||
const [workedVariants, setWorkedVariants] = useState(true);
|
||||
// Self-spot. SELF_SPOT_MIN_MIN mirrors the backend floor — the input clamps on
|
||||
// blur, not per keystroke, or typing "10" would be rewritten to "5" the moment
|
||||
// the "1" landed and the field would fight the operator.
|
||||
@@ -1632,6 +1635,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||||
try { setWorkedVariants(await GetWorkedCallVariants()); } catch {}
|
||||
reloadAudioDevices();
|
||||
reloadDvk();
|
||||
} catch (e: any) {
|
||||
@@ -5666,6 +5670,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
|
||||
{t('gen.autofocusWB')}
|
||||
</label>
|
||||
{/* Backend setting, not a UI pref: the fold happens in the SQL. Saved
|
||||
instantly like the rest of this panel. */}
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={workedVariants}
|
||||
onCheckedChange={(c) => { const v = !!c; setWorkedVariants(v); SetWorkedCallVariants(v).catch((e: any) => setErr(String(e?.message ?? e))); }} />
|
||||
{t('gen.workedVariants')} <span className="text-xs text-muted-foreground">{t('gen.workedVariantsHint')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={showBeamMap} onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} />
|
||||
{t('gen.showBeam')}
|
||||
|
||||
@@ -186,6 +186,7 @@ const en: Dict = {
|
||||
// General panel
|
||||
'gen.hint': 'App behaviour (saved instantly).',
|
||||
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
||||
'gen.workedVariants': '"Worked before" folds portable callsigns together', 'gen.workedVariantsHint': '(RK3DWA also finds RK3DWA/3, /P, /MM — and the other way round. Contest dupe checking stays exact.)',
|
||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
||||
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
||||
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
||||
@@ -607,6 +608,7 @@ const fr: Dict = {
|
||||
'relayauto.from': 'de', 'relayauto.to': 'à',
|
||||
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
||||
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||
'gen.workedVariants': '« Déjà contacté » regroupe les indicatifs portables', 'gen.workedVariantsHint': '(RK3DWA trouve aussi RK3DWA/3, /P, /MM — et inversement. Le contrôle de doublon en concours reste strict.)',
|
||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
||||
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
||||
|
||||
Vendored
+4
@@ -515,6 +515,8 @@ export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function GetWorkedCallVariants():Promise<boolean>;
|
||||
|
||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||
|
||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||
@@ -967,6 +969,8 @@ export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||
|
||||
@@ -978,6 +978,10 @@ export function GetWinkeyerStatus() {
|
||||
return window['go']['main']['App']['GetWinkeyerStatus']();
|
||||
}
|
||||
|
||||
export function GetWorkedCallVariants() {
|
||||
return window['go']['main']['App']['GetWorkedCallVariants']();
|
||||
}
|
||||
|
||||
export function GetYaesuState() {
|
||||
return window['go']['main']['App']['GetYaesuState']();
|
||||
}
|
||||
@@ -1882,6 +1886,10 @@ export function SetWinkeyerTrace(arg1) {
|
||||
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
|
||||
}
|
||||
|
||||
export function SetWorkedCallVariants(arg1) {
|
||||
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAFGain(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package qso
|
||||
|
||||
import "testing"
|
||||
|
||||
// The predicate behind "Worked before". Exact when folding is off; with it on,
|
||||
// a station's portable forms are one operator — and the fold has to work from
|
||||
// either end, because you may type the base call or the portable one.
|
||||
func TestCallMatch(t *testing.T) {
|
||||
if pred, args := callMatch("RK3DWA", false); pred != "callsign = ?" || len(args) != 1 || args[0] != "RK3DWA" {
|
||||
t.Errorf("exact: got %q %v", pred, args)
|
||||
}
|
||||
|
||||
// Typing the base call: match it and everything suffixed off it.
|
||||
pred, args := callMatch("RK3DWA", true)
|
||||
if pred != "(callsign = ? OR callsign LIKE ?)" {
|
||||
t.Errorf("variants predicate = %q", pred)
|
||||
}
|
||||
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" {
|
||||
t.Errorf("variants args = %v, want [RK3DWA RK3DWA/%%]", args)
|
||||
}
|
||||
|
||||
// Typing a portable form must reach the plain call too — the suffix is
|
||||
// stripped from the INPUT, not just matched in the column.
|
||||
_, args = callMatch("RK3DWA/3", true)
|
||||
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" {
|
||||
t.Errorf("portable input args = %v, want [RK3DWA RK3DWA/%%]", args)
|
||||
}
|
||||
|
||||
// A leading slash is not a suffix marker — dropping to "" there would match
|
||||
// the entire logbook.
|
||||
if _, args := callMatch("/RK3DWA", true); args[0] != "/RK3DWA" {
|
||||
t.Errorf("leading slash: args[0] = %v, want the call unchanged", args[0])
|
||||
}
|
||||
}
|
||||
+32
-7
@@ -1585,11 +1585,35 @@ type BandMode struct {
|
||||
// rendering a recent-contacts mini-list.
|
||||
const maxWorkedEntries = 50
|
||||
|
||||
// callMatch builds the WHERE fragment that selects one station's QSOs.
|
||||
//
|
||||
// Exact by default. With variants on, an operator's portable forms count as the
|
||||
// same station: RK3DWA, RK3DWA/3, RK3DWA/P and RK3DWA/QRP are one person, and
|
||||
// someone asking "have I worked RK3DWA?" means the person, not the string.
|
||||
// Typing 21 QSOs' worth of history only when you happen to add "/3" is the
|
||||
// behaviour this replaces. The suffix is stripped from what was TYPED too, so
|
||||
// it matches both ways round — RK3DWA/3 also finds the plain RK3DWA contacts.
|
||||
//
|
||||
// Deliberately NOT a bare "starts with": LIKE 'RK3DWA%' would also drag in
|
||||
// RK3DWAB, which is a different station. The '/' is what makes it the same one.
|
||||
func callMatch(call string, variants bool) (string, []any) {
|
||||
if !variants {
|
||||
return "callsign = ?", []any{call}
|
||||
}
|
||||
base := call
|
||||
if i := strings.IndexByte(base, '/'); i > 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
return "(callsign = ? OR callsign LIKE ?)", []any{base, base + "/%"}
|
||||
}
|
||||
|
||||
// WorkedBefore returns aggregated history at both callsign and DXCC level.
|
||||
// dxccHint lets the caller pass a known DXCC number (e.g. from a fresh QRZ
|
||||
// lookup) when the call has never been worked. If 0, the DXCC is inferred
|
||||
// from the most recent prior QSO with the same callsign.
|
||||
func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int) (WorkedBefore, error) {
|
||||
//
|
||||
// matchVariants folds the portable forms of the call together — see callMatch.
|
||||
func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int, matchVariants bool) (WorkedBefore, error) {
|
||||
wb := WorkedBefore{
|
||||
Callsign: upperTrim(callsign),
|
||||
Bands: []string{},
|
||||
@@ -1605,17 +1629,18 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
}
|
||||
|
||||
// ---- Per-callsign stats ----
|
||||
pred, predArgs := callMatch(wb.Callsign, matchVariants)
|
||||
if err := r.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
|
||||
`SELECT COUNT(*) FROM qso WHERE `+pred, predArgs...).Scan(&wb.Count); err != nil {
|
||||
return wb, fmt.Errorf("count worked: %w", err)
|
||||
}
|
||||
if wb.Count > 0 {
|
||||
// Pull the full QSO records (same columns as the Recent QSOs list) so
|
||||
// the Worked-before grid can offer the same rich column picker.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
|
||||
FROM qso WHERE callsign = ?
|
||||
FROM qso WHERE `+pred+`
|
||||
ORDER BY qso_date DESC, id DESC
|
||||
LIMIT ?`, wb.Callsign, maxWorkedEntries)
|
||||
LIMIT ?`, append(append([]any{}, predArgs...), maxWorkedEntries)...)
|
||||
if err != nil {
|
||||
return wb, fmt.Errorf("query worked: %w", err)
|
||||
}
|
||||
@@ -1648,7 +1673,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
if wb.Count > maxWorkedEntries {
|
||||
var firstStr sql.NullString
|
||||
_ = r.db.QueryRowContext(ctx,
|
||||
`SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr)
|
||||
`SELECT MIN(qso_date) FROM qso WHERE `+pred, predArgs...).Scan(&firstStr)
|
||||
if firstStr.Valid {
|
||||
wb.First = parseTimeLoose(firstStr.String)
|
||||
}
|
||||
@@ -1673,8 +1698,8 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
var d sql.NullInt64
|
||||
_ = r.db.QueryRowContext(ctx, `
|
||||
SELECT dxcc FROM qso
|
||||
WHERE callsign = ? AND dxcc IS NOT NULL
|
||||
ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d)
|
||||
WHERE `+pred+` AND dxcc IS NOT NULL
|
||||
ORDER BY qso_date DESC LIMIT 1`, predArgs...).Scan(&d)
|
||||
if d.Valid {
|
||||
dxcc = int(d.Int64)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user