fix(awards): apply the per-reference validity window

References are not forever: a park is delisted, a district merged, a castle
loses its number. A QSO made while the reference existed is a valid contact
and must keep counting; one made afterwards must not.

The JSON format did not need to change — ValidFrom/ValidTo were already on
awardref.Ref, already columns in award_references, already round-tripping
through export and import. They were simply never read: awardRefMetas dropped
them on the way into the engine, so nothing downstream could enforce them.
This carries them through and checks them in keepRefs.

Empty means the award's own window governs, which inScope already enforces for
every QSO in the award. That fallback is deliberately NOT duplicated per
reference — two places enforcing the same dates is two places for them to
disagree. The editor shows the award's dates as the hint under the boxes so
the operator can see what empty inherits.

Dates are compared as ISO strings rather than parsed times: the stored shape
is "2006-01-02", lexical order on it IS chronological, and this cannot fail on
a malformed value the way a parse can — a reference with a typo in its window
keeps counting instead of silently vanishing from an operator's totals.

The Explain trace names the date and the cutoff, because "did not count" on a
contact the operator remembers making is exactly when they need to be told it
is the reference that has a window, not their log that is wrong.
This commit is contained in:
2026-08-12 10:43:23 +02:00
parent d33291b521
commit 8fc6673611
6 changed files with 160 additions and 11 deletions
+50 -5
View File
@@ -449,6 +449,34 @@ type RefMeta struct {
Pattern string
re *regexp.Regexp
Valid bool
// Per-reference validity window, ISO "2006-01-02". A reference is not
// forever: a park is delisted, a county is merged, a castle loses its
// reference number. A QSO made while it existed still counts — it was a valid
// contact on the day — and one made after it stopped existing does not.
//
// Empty means "no window of its own", and the award's own ValidFrom/ValidTo
// then govern, as they already do for every QSO in the award (see inScope).
// That fallback is deliberately NOT duplicated here: two places enforcing the
// same dates is two places for them to disagree.
ValidFrom string
ValidTo string
}
// activeOn reports whether the reference existed on the day of the QSO.
//
// Compared as ISO date strings rather than parsed times on purpose: the stored
// values are "2025-08-01"-shaped and lexical order on that shape IS
// chronological order, so this cannot fail on a malformed date the way a parse
// can — a reference with a typo in its window keeps counting instead of silently
// vanishing from an operator's totals.
func (m RefMeta) activeOn(day string) bool {
if m.ValidFrom != "" && day < m.ValidFrom {
return false
}
if m.ValidTo != "" && day > m.ValidTo {
return false
}
return true
}
// NewRefList builds the engine's reference view from (code, meta) pairs.
@@ -956,11 +984,13 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// describes is worse than no trace, because it is believed.
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
predefined := hasList && !d.Dynamic
// The day of the contact, for per-reference validity windows.
day := q.QSODate.Format("2006-01-02")
// run executes one rule and, when tracing, records it.
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
kept := keepRefs(predefined, rl, raw)
kept := keepRefs(predefined, rl, raw, day)
if ex != nil {
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
@@ -974,7 +1004,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
if _, ok := keptSet[n]; ok {
continue
}
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
s.Rejected = append(s.Rejected, rejection(predefined, rl, n, day))
}
ex.Steps = append(ex.Steps, s)
}
@@ -1026,7 +1056,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
// awards panel and the per-QSO refs editor — honours overrides too. For a
// predefined award the ref is still validated against the list below.
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
manual := keepRefs(predefined, rl, manualRefs(q, d.Code), day)
if ex != nil {
ex.Manual = manual
}
@@ -1065,7 +1095,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
// become a reference. "Nothing matched" is the least useful thing a matcher can
// say; every one of this week's award bugs was a rejection with a plain reason
// that nothing was printing.
func rejection(predefined bool, rl refList, code string) Rejected {
func rejection(predefined bool, rl refList, code, day string) Rejected {
switch {
case code == "":
return Rejected{Candidate: code, Reason: "empty"}
@@ -1076,6 +1106,17 @@ func rejection(predefined bool, rl refList, code string) Rejected {
if !ok {
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
}
// Spell the dates out. "Did not count" on a contact the operator remembers
// making is exactly the moment they need to be told it is the REFERENCE that
// has a window, not their log that is wrong.
if !m.activeOn(day) {
switch {
case m.ValidTo != "" && day > m.ValidTo:
return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference ceased to exist on %s, after this QSO of %s", m.ValidTo, day)}
default:
return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference did not exist until %s, after this QSO of %s", m.ValidFrom, day)}
}
}
if !m.Valid {
return Rejected{Candidate: code, Reason: "listed but disabled"}
}
@@ -1088,7 +1129,7 @@ func rejection(predefined bool, rl refList, code string) Rejected {
// so we do NOT additionally require the QSO's entity to match the reference's own
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
// 291). Per-reference DXCC stays metadata for the picker.
func keepRefs(predefined bool, rl refList, found []string) []string {
func keepRefs(predefined bool, rl refList, found []string, day string) []string {
if !predefined {
out := make([]string, 0, len(found))
for _, c := range found {
@@ -1106,6 +1147,10 @@ func keepRefs(predefined bool, rl refList, found []string) []string {
if !ok || !m.Valid {
continue
}
// The reference has to have existed on the day of the contact.
if !m.activeOn(day) {
continue
}
if _, dup := seen[c]; dup {
continue
}
+78
View File
@@ -0,0 +1,78 @@
package award
import (
"testing"
"time"
"hamlog/internal/qso"
)
func day(s string) time.Time {
t, err := time.Parse("2006-01-02", s)
if err != nil {
panic(err)
}
return t
}
// A reference is not forever. A park is delisted, a district is merged, a castle
// loses its number. A contact made while it existed still counts — it was a
// valid contact on the day — and one made after it stopped existing does not.
func TestRefValidityWindow(t *testing.T) {
m := RefMeta{Code: "KL-01", Valid: true, ValidTo: "2025-08-31"}
for _, tc := range []struct {
day string
want bool
}{
{"2019-01-01", true},
{"2025-08-31", true}, // the last day it existed still counts
{"2025-09-01", false},
{"2026-08-12", false},
} {
if got := m.activeOn(tc.day); got != tc.want {
t.Errorf("KL-01 on %s: active=%v, want %v", tc.day, got, tc.want)
}
}
// A reference that only came into being partway through.
n := RefMeta{Code: "KL-99", Valid: true, ValidFrom: "2025-01-15"}
if n.activeOn("2025-01-14") {
t.Error("counted a QSO from before the reference existed")
}
if !n.activeOn("2025-01-15") {
t.Error("the first day it existed must count")
}
// No window of its own: the award's own dates govern, as they already do for
// every QSO in the award. Nothing here may narrow that.
if !(RefMeta{Code: "X", Valid: true}).activeOn("1970-01-01") {
t.Error("a reference with no window must count on any date")
}
}
// The whole point: the same QSO counts before the cutoff and does not after.
func TestExpiredRefStopsCountingForLaterQSOs(t *testing.T) {
d := &Def{
Code: "RDA", Name: "Russian District Award", Valid: true,
Type: TypeQSOFields, Field: "note", MatchBy: "code",
Confirm: []string{"lotw"},
}
metas := []RefMeta{
{Code: "KL-01", Name: "Petrozavodsk", Valid: true, ValidTo: "2025-08-31"},
{Code: "KL-04", Name: "Kostomuksha", Valid: true},
}
q := func(ref, on string) *qso.QSO {
return &qso.QSO{Callsign: "RA1ABC", Band: "20m", Notes: ref, QSODate: day(on)}
}
if got := MatchQSO(*d, metas, q("KL-01", "2025-06-01")); len(got) != 1 || got[0] != "KL-01" {
t.Errorf("a QSO made while KL-01 existed must count: got %v", got)
}
if got := MatchQSO(*d, metas, q("KL-01", "2025-09-15")); len(got) != 0 {
t.Errorf("a QSO made after KL-01 ceased to exist must not count: got %v", got)
}
if got := MatchQSO(*d, metas, q("KL-04", "2025-09-15")); len(got) != 1 || got[0] != "KL-04" {
t.Errorf("a reference with no window is unaffected: got %v", got)
}
}