From 8fc667361144d5829e8f8e332a8ed35d44e65638 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Wed, 12 Aug 2026 10:43:23 +0200 Subject: [PATCH] fix(awards): apply the per-reference validity window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 1 + changelog.json | 6 +- frontend/src/components/AwardEditor.tsx | 27 ++++++++- frontend/src/lib/i18n.tsx | 4 +- internal/award/award.go | 55 +++++++++++++++-- internal/award/refvalidity_test.go | 78 +++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 internal/award/refvalidity_test.go diff --git a/app.go b/app.go index 1da3e95..d1fdc47 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/changelog.json b/changelog.json index dcd8fa0..d6b5b4a 100644 --- a/changelog.json +++ b/changelog.json @@ -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." ] }, { diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index 0b13a03..4cdad24 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -778,6 +778,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { 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. */} patchSel({ gridsquare: e.target.value })} /> + {/* 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. */} +
+ + patchSel({ valid_from: e.target.value })} /> + + + patchSel({ valid_to: e.target.value })} /> + +
+

+ {(awardValidFrom || awardValidTo) + ? t('awed.refValidHintAward', { from: awardValidFrom || '—', to: awardValidTo || '—' }) + : t('awed.refValidHint')} +

)} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 0cc01c0..d85a4d1 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -387,7 +387,7 @@ const en: Dict = { 'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.', 'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.', 'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.prefixCol': 'Prefix', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.', - 'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.exportedOneTo': '{code} exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefix': 'Prefix', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.customLabel': 'Custom source', 'awed.customField': 'ADIF field', 'awed.customValue': 'Value(s) that confirm (optional)', 'awed.customHint': 'For confirmations OpsLog has no column of its own for. Name any QSO field or ADIF tag — APP_OPSLOG_QSL_RCVD for a card received through OpsLog, or a tag a club list stamped on import. Leave the value empty and ANY non-empty content confirms (the OpsLog marker stores a date, not Y/N); give a comma-separated list to require one of them. A custom source naming no field confirms nothing.', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference', + 'awed.addCountry': 'Add country…', 'awed.refValidFrom': 'Valid from', 'awed.refValidTo': 'Valid to', 'awed.refValidHint': 'A QSO only counts for this reference if it was made inside this window. Leave empty if the reference has always existed.', 'awed.refValidHintAward': 'Leave empty to use the award’s own window ({from} → {to}).', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.exportedOneTo': '{code} exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefix': 'Prefix', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.customLabel': 'Custom source', 'awed.customField': 'ADIF field', 'awed.customValue': 'Value(s) that confirm (optional)', 'awed.customHint': 'For confirmations OpsLog has no column of its own for. Name any QSO field or ADIF tag — APP_OPSLOG_QSL_RCVD for a card received through OpsLog, or a tag a club list stamped on import. Leave the value empty and ANY non-empty content confirms (the OpsLog marker stores a date, not Y/N); give a comma-separated list to require one of them. A custom source naming no field confirms nothing.', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference', 'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine', 'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testAmbiguous': 'Ambiguous', 'awed.testAmbiguousHint': '— none kept, this award allows one reference per QSO. Assign the right one by hand.', 'awed.testSameAs': '+{n} other QSO(s), same result', 'awed.exportOne': 'Share {code}', @@ -796,7 +796,7 @@ const fr: Dict = { 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', 'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.', 'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.prefixCol': 'Préfixe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.', - 'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.exportedOneTo': '{code} exporté vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'’en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefix': 'Préfixe', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.customLabel': 'Source personnalisée', 'awed.customField': 'Champ ADIF', 'awed.customValue': 'Valeur(s) qui confirment (facultatif)', 'awed.customHint': "Pour les confirmations dont OpsLog n'a pas de colonne dédiée. Indique n'importe quel champ de QSO ou balise ADIF — APP_OPSLOG_QSL_RCVD pour une carte reçue via OpsLog, ou une balise inscrite à l'import d'une liste de club. Laisse la valeur vide et TOUT contenu non vide confirme (le marqueur OpsLog stocke une date, pas un Y/N) ; mets une liste séparée par des virgules pour en exiger une. Une source personnalisée sans champ ne confirme rien.", 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', + 'awed.addCountry': 'Ajouter un pays…', 'awed.refValidFrom': 'Valide à partir du', 'awed.refValidTo': 'Valide jusqu’au', 'awed.refValidHint': 'Un QSO ne compte pour cette référence que s’il a été fait dans cette fenêtre. Laisser vide si la référence a toujours existé.', 'awed.refValidHintAward': 'Laisser vide pour utiliser la fenêtre du diplôme ({from} → {to}).', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.exportedOneTo': '{code} exporté vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'’en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefix': 'Préfixe', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.customLabel': 'Source personnalisée', 'awed.customField': 'Champ ADIF', 'awed.customValue': 'Valeur(s) qui confirment (facultatif)', 'awed.customHint': "Pour les confirmations dont OpsLog n'a pas de colonne dédiée. Indique n'importe quel champ de QSO ou balise ADIF — APP_OPSLOG_QSL_RCVD pour une carte reçue via OpsLog, ou une balise inscrite à l'import d'une liste de club. Laisse la valeur vide et TOUT contenu non vide confirme (le marqueur OpsLog stocke une date, pas un Y/N) ; mets une liste séparée par des virgules pour en exiger une. Une source personnalisée sans champ ne confirme rien.", 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', 'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes', 'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testAmbiguous': 'Ambigu', 'awed.testAmbiguousHint': '— aucune retenue, ce diplôme n’admet qu’une référence par QSO. Affectez la bonne à la main.', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat', 'awed.exportOne': 'Partager {code}', diff --git a/internal/award/award.go b/internal/award/award.go index 1f3e00f..4d0cd21 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -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 } diff --git a/internal/award/refvalidity_test.go b/internal/award/refvalidity_test.go new file mode 100644 index 0000000..ae6dcdc --- /dev/null +++ b/internal/award/refvalidity_test.go @@ -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) + } +}