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:
2026-08-08 23:24:23 +02:00
parent 1d8cd25205
commit 642ed358c2
8 changed files with 134 additions and 8 deletions
+34
View File
@@ -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
View File
@@ -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)
}