feat: filter award references by mode class

All / CW / Phone / Digital, stacked on top of the worked/confirmed filter. The
question an operator actually has — which DXCC entities have I worked on CW but
not confirmed on CW — needs both at once; answering only one of them is what
sends people to a spreadsheet.

References carried bands but not modes, so the award computation now aggregates
mode CLASSES per reference, worked and confirmed separately. Classes, not ADIF
modes: nobody chasing CW cares whether the digital side was FT8 or RTTY, and a
list of twenty mode names would not answer the question. An unrecognised mode
counts as data rather than as nothing, so a new digital mode does not vanish
from the filter the day it appears.

Two rules the filter needs to be honest:

  - Not-confirmed means not confirmed ON THIS MODE. An entity worked on CW and
    confirmed on SSB is still a CW entity to chase, and that is exactly what the
    filter is for.
  - "Not worked" plus a mode is a contradiction — an unworked reference has no
    mode — so the mode filter stands aside there instead of emptying the list.
This commit is contained in:
2026-07-31 16:09:11 +02:00
parent d90f953df4
commit 3e22c4d1a3
6 changed files with 111 additions and 13 deletions
+48 -8
View File
@@ -334,13 +334,19 @@ type BandCount struct {
// Ref is one reference's status within an award.
type Ref struct {
Ref string `json:"ref"`
Name string `json:"name,omitempty"`
Group string `json:"group,omitempty"`
SubGrp string `json:"subgrp,omitempty"`
Worked bool `json:"worked"`
Confirmed bool `json:"confirmed"`
Validated bool `json:"validated"`
Ref string `json:"ref"`
Name string `json:"name,omitempty"`
Group string `json:"group,omitempty"`
SubGrp string `json:"subgrp,omitempty"`
Worked bool `json:"worked"`
Confirmed bool `json:"confirmed"`
Validated bool `json:"validated"`
// Modes / ConfirmedModes are CLASSES, not ADIF modes: "CW", "PHONE",
// "DIGI". An operator asking "which entities have I worked on CW but not
// confirmed" does not care whether it was FT8 or RTTY on the digital side,
// and a list of twenty mode names would not answer the question they asked.
Modes []string `json:"modes"`
ConfirmedModes []string `json:"confirmed_modes"`
Bands []string `json:"bands"`
ConfirmedBands []string `json:"confirmed_bands"`
ValidatedBands []string `json:"validated_bands"`
@@ -360,11 +366,33 @@ type Result struct {
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
}
// ModeClass sorts an ADIF mode into the three classes an operator thinks in:
// CW, PHONE, DIGI. Anything unrecognised returns "" and is simply not counted
// under any class — better than inventing one, since these classes drive a
// filter that decides what the operator is shown.
func ModeClass(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW", "CWR":
return "CW"
case "SSB", "USB", "LSB", "AM", "FM", "DV", "PHONE", "DIGITALVOICE":
return "PHONE"
case "":
return ""
}
// Everything else that a logbook actually carries is a data mode: FT8, FT4,
// RTTY, PSK31, JS8, Q65, MSK144, OLIVIA, VARA… Listing them exhaustively
// would mean a new mode silently vanishing from the filter the day it
// appears, which is worse than treating an unknown data mode as data.
return "DIGI"
}
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
type NameResolver func(field, ref string) string
type refAgg struct {
bands map[string]struct{}
modes map[string]struct{}
confirmedModes map[string]struct{}
confirmedBands map[string]struct{}
validatedBands map[string]struct{}
anyConfirmed bool
@@ -481,22 +509,33 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
continue
}
band := strings.ToLower(strings.TrimSpace(q.Band))
modeClass := ModeClass(q.Mode)
isConf := confirmed(q, d.Confirm)
isVal := confirmed(q, d.Validate)
for _, ref := range refs {
a := agg[i][ref]
if a == nil {
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
a = &refAgg{
bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{},
validatedBands: map[string]struct{}{},
modes: map[string]struct{}{}, confirmedModes: map[string]struct{}{},
}
agg[i][ref] = a
}
if band != "" {
a.bands[band] = struct{}{}
}
if modeClass != "" {
a.modes[modeClass] = struct{}{}
}
if isConf {
a.anyConfirmed = true
if band != "" {
a.confirmedBands[band] = struct{}{}
}
if modeClass != "" {
a.confirmedModes[modeClass] = struct{}{}
}
}
if isVal {
a.anyValidated = true
@@ -525,6 +564,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
r.Validated++
}
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
Modes: setToSorted(a.modes), ConfirmedModes: setToSorted(a.confirmedModes),
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
labelRef(&rf, d, ref, rl, hasList, nameOf)
r.Refs = append(r.Refs, rf)
+28
View File
@@ -0,0 +1,28 @@
package award
import "testing"
// The three classes an operator thinks in — the ones the awards filter offers.
//
// "Which entities have I worked on CW but not confirmed" is the question this
// serves, so the classes must match how the question is asked: CW, phone, data.
// Which data mode it was does not enter into it.
func TestModeClass(t *testing.T) {
for _, c := range []struct{ mode, want string }{
{"CW", "CW"}, {"cw", "CW"}, {" CW ", "CW"}, {"CWR", "CW"},
{"SSB", "PHONE"}, {"USB", "PHONE"}, {"LSB", "PHONE"},
{"AM", "PHONE"}, {"FM", "PHONE"}, {"DV", "PHONE"},
{"FT8", "DIGI"}, {"FT4", "DIGI"}, {"RTTY", "DIGI"}, {"PSK31", "DIGI"},
{"JS8", "DIGI"}, {"Q65", "DIGI"}, {"OLIVIA", "DIGI"},
// A mode nobody has heard of yet is data, not nothing: the alternative
// is a new digital mode silently vanishing from the filter the day it
// appears.
{"SOMETHINGNEW", "DIGI"},
// No mode at all is not a class, and must not be counted as one.
{"", ""}, {" ", ""},
} {
if got := ModeClass(c.mode); got != c.want {
t.Errorf("ModeClass(%q) = %q, want %q", c.mode, got, c.want)
}
}
}