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:
@@ -4486,6 +4486,7 @@ func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta {
|
||||
metas = append(metas, award.RefMeta{
|
||||
Code: rf.Code, Name: rf.Name, Group: rf.Group, SubGrp: rf.SubGrp,
|
||||
DXCCList: dxccList, Pattern: rf.Pattern, Valid: rf.Valid,
|
||||
ValidFrom: rf.ValidFrom, ValidTo: rf.ValidTo,
|
||||
})
|
||||
}
|
||||
out[code] = metas
|
||||
|
||||
+4
-2
@@ -9,7 +9,8 @@
|
||||
"PowerGenius XL: the Station Control card now shows power, current, SWR and temperature without a FlexRadio. The meters were only ever drawn from the radio's stream, so a station on any other rig got an empty card while the amplifier was reporting all four over its own link.",
|
||||
"Motorized antennas (Ultrabeam and SteppIR): tracking now offers the three modes the SteppIR controller software has — every frequency change, past a step of 25/50/100 kHz, or only when the band changes. Existing setups keep the step mode they already had.",
|
||||
"Motorized antennas: each covered band now has its own tune frequency, set in a box under the band in Settings, and that is where the band button in Station Control sends the antenna. Left empty a band keeps its default, and a frequency that is not in its band is refused rather than sent to the elements.",
|
||||
"Awards: a single award can now be exported on its own, next to the whole-catalogue export. Sharing one award meant handing over your entire catalogue."
|
||||
"Awards: a single award can now be exported on its own, next to the whole-catalogue export. Sharing one award meant handing over your entire catalogue.",
|
||||
"Awards: each reference now has its own validity window, so a reference that ceased to exist counts for QSOs made while it existed and not for later ones. The dates were already stored and were never applied; left empty a reference follows the award's own window."
|
||||
],
|
||||
"fr": [
|
||||
"Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main. Le chemin UDP posait le profil station, le DXCC et les défauts QSL mais ni SFI, ni A, ni K, ni distance — un opérateur en numérique avait donc ces champs vides sur tout son log. La météo spatiale n est posée que sur un contact de moins d un jour : sinon un logiciel qui rediffuse son historique se verrait attribuer les relevés de ce matin sur des contacts du mois dernier.",
|
||||
@@ -18,7 +19,8 @@
|
||||
"PowerGenius XL : la carte du Contrôle station affiche désormais puissance, courant, ROS et température sans FlexRadio. Les mesures n étaient tirées que du flux de la radio, si bien qu une station sur une autre radio n avait qu une carte vide alors que l amplificateur remontait les quatre sur sa propre liaison.",
|
||||
"Antennes motorisées (Ultrabeam et SteppIR) : le suivi propose désormais les trois modes du logiciel du contrôleur SteppIR — à chaque changement de fréquence, au-delà d un pas de 25/50/100 kHz, ou seulement au changement de bande. Les installations existantes conservent le mode par pas qu elles avaient déjà.",
|
||||
"Antennes motorisées : chaque bande couverte a désormais sa propre fréquence d accord, saisie dans une case sous la bande dans les Réglages, et c est là que le bouton de bande du Contrôle station envoie l antenne. Laissée vide, une bande garde son défaut, et une fréquence hors de sa bande est refusée plutôt qu envoyée aux éléments.",
|
||||
"Diplômes : un diplôme peut désormais être exporté seul, à côté de l export du catalogue complet. Partager un seul diplôme obligeait à livrer tout son catalogue."
|
||||
"Diplômes : un diplôme peut désormais être exporté seul, à côté de l export du catalogue complet. Partager un seul diplôme obligeait à livrer tout son catalogue.",
|
||||
"Diplômes : chaque référence a désormais sa propre fenêtre de validité, si bien qu une référence qui a cessé d exister compte pour les QSO faits de son vivant et pas pour les suivants. Les dates étaient déjà stockées et n étaient jamais appliquées ; laissée vide, une référence suit la fenêtre du diplôme."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -778,6 +778,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<TabsContent value="refs" className="mt-0">
|
||||
<ReferencesPanel
|
||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||
awardValidFrom={cur.valid_from} awardValidTo={cur.valid_to}
|
||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||
onChanged={loadMeta} setErr={setErr}
|
||||
/>
|
||||
@@ -899,8 +900,8 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||
// the online updater (POTA/SOTA/WWFF).
|
||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
||||
code: string; presets: Preset[]; meta?: RefMeta;
|
||||
function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, onUpdateOnline, updating, onChanged, setErr }: {
|
||||
code: string; presets: Preset[]; meta?: RefMeta; awardValidFrom?: string; awardValidTo?: string;
|
||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -1056,6 +1057,28 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
third-party list may carry the values — but they are not offered
|
||||
for editing until something actually reads them. */}
|
||||
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||
{/* This reference's own validity window. A reference is not forever:
|
||||
a park is delisted, a district merged. A QSO made while it
|
||||
existed still counts — it was a valid contact on the day — and
|
||||
one made afterwards does not.
|
||||
Left empty the award's own dates govern, which is why they show
|
||||
as the placeholder: the operator can see what "empty" inherits
|
||||
instead of having to remember. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field2 label={t('awed.refValidFrom')}>
|
||||
<Input type="date" className="h-8" value={sel.valid_from ?? ''}
|
||||
onChange={(e) => patchSel({ valid_from: e.target.value })} />
|
||||
</Field2>
|
||||
<Field2 label={t('awed.refValidTo')}>
|
||||
<Input type="date" className="h-8" value={sel.valid_to ?? ''}
|
||||
onChange={(e) => patchSel({ valid_to: e.target.value })} />
|
||||
</Field2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(awardValidFrom || awardValidTo)
|
||||
? t('awed.refValidHintAward', { from: awardValidFrom || '—', to: awardValidTo || '—' })
|
||||
: t('awed.refValidHint')}
|
||||
</p>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+50
-5
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user