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
+25 -2
View File
@@ -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