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)