From 128364260b2efb87db8a76117344f8cb4cf6f874 Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 7 Jul 2026 21:08:26 +0200 Subject: [PATCH] fix: bug when deleting an award reference which was not saved. --- frontend/src/App.tsx | 12 ++++++--- frontend/src/components/AwardsPanel.tsx | 4 +-- frontend/src/components/QSOEditModal.tsx | 6 ++++- frontend/src/lib/awardRefs.ts | 31 +++++++++++++++++++++++- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b31a6b4..8b0b98a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1007,11 +1007,15 @@ export default function App() { // list once, then compute each shown QSO's reference per award and attach it // to the rows (the grids render one hideable column per award). const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]); + // Bumped whenever award definitions are saved so the grid columns AND the + // per-QSO refs re-fetch — the ref/name display choice is computed live, so + // changing it updates ALL contacts (old included) with no restart. + const [awardsVersion, setAwardsVersion] = useState(0); useEffect(() => { GetAwardDefs().then((defs: any[]) => setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))), ).catch(() => {}); - }, []); + }, [awardsVersion]); const [qsoAwardRefs, setQsoAwardRefs] = useState>>({}); useEffect(() => { const ids = (qsos as any[]).map((q) => q.id).filter(Boolean); @@ -1019,7 +1023,7 @@ export default function App() { let alive = true; AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {}); return () => { alive = false; }; - }, [qsos, awardCols.length]); + }, [qsos, awardCols.length, awardsVersion]); const qsosWithAwards = useMemo( () => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })), [qsos, qsoAwardRefs], @@ -1031,7 +1035,7 @@ export default function App() { let alive = true; AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {}); return () => { alive = false; }; - }, [wb, awardCols.length]); + }, [wb, awardCols.length, awardsVersion]); const wbWithAwards = useMemo( () => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null), [wb, wbAwardRefs], @@ -4169,7 +4173,7 @@ export default function App() { - + setAwardsVersion((v) => v + 1)} /> {contestTabEnabled && ( diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx index eec6d85..6f9375e 100644 --- a/frontend/src/components/AwardsPanel.tsx +++ b/frontend/src/components/AwardsPanel.tsx @@ -61,7 +61,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed: type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] }; -export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) { +export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) { const { t } = useI18n(); const [awardList, setAwardList] = useState([]); // Computed results are cached per award code — each award is scanned only the @@ -220,7 +220,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } {t('awp.rescan')} - setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} /> + setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} /> {/* Quick selector — scales when there are many awards. */} {awardList.length > 0 && (
diff --git a/frontend/src/components/QSOEditModal.tsx b/frontend/src/components/QSOEditModal.tsx index 1da9652..b0dc78a 100644 --- a/frontend/src/components/QSOEditModal.tsx +++ b/frontend/src/components/QSOEditModal.tsx @@ -187,6 +187,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b // WPX, …) are derived from the QSO by the backend and shown read-only. const awardFieldRef = useRef>({}); const [awardRefs, setAwardRefs] = useState(''); + // The refs present when the editor opened, so on save we can tell which ones + // were REMOVED and strip their in-field tokens (see applyAwardRefs). + const seedAwardRefsRef = useRef(''); const [computedRefs, setComputedRefs] = useState>([]); // Load award definitions once, then seed the editable manual refs from the QSO. @@ -207,6 +210,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b .filter((r: any) => r.pickable) .map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`) .join(';'); + seedAwardRefsRef.current = seed; setAwardRefs(seed); } catch { /* leave manual refs empty on failure */ } }) @@ -327,7 +331,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b // the dedicated columns, then route the picked refs back onto the payload // (POTA/SOTA/IOTA → columns, WWFF/custom → extras). out.iota = ''; out.sota_ref = ''; out.pota_ref = ''; - applyAwardRefs(out, awardRefs, awardFieldRef.current); + applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current); onSave(out); } diff --git a/frontend/src/lib/awardRefs.ts b/frontend/src/lib/awardRefs.ts index 40930f8..89162fc 100644 --- a/frontend/src/lib/awardRefs.ts +++ b/frontend/src/lib/awardRefs.ts @@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string { return out; } +// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a +// text field — the inverse of appendTokens. Used when a picked reference is +// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it; +// otherwise the ref is re-derived from the field and reappears on reopen. +function removeTokens(existing: string | undefined, refs: string): string { + let out = existing ?? ''; + for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) { + const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig'); + out = out.replace(re, ' '); + } + return out.replace(/\s{2,}/g, ' ').trim(); +} + // MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding // the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF". // The award engine honours these regardless of how the award matches, so a @@ -47,8 +60,24 @@ const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS'; // data lives in its conventional place and exports correctly. Awards that match // a free-text field by description/pattern (address/qth/name/custom) rely solely // on the override — we don't pollute the text field with a code. -export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record) { +export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record, prevAwardRefs?: string) { const byCode = parseAwardRefs(awardRefs); + // References the operator REMOVED since the editor opened: for in-field awards + // (note/comment) strip the token from the field too, so a deleted DDFM/etc. + // ref doesn't get re-derived from the leftover token and reappear on reopen. + if (prevAwardRefs != null) { + const prev = parseAwardRefs(prevAwardRefs); + for (const [code, prevRef] of Object.entries(prev)) { + const field = fieldOf[code] || code.toLowerCase(); + if (field !== 'note' && field !== 'notes' && field !== 'comment') continue; + const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)); + const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase())); + if (removed.length === 0) continue; + const rm = removed.join(','); + if (field === 'comment') payload.comment = removeTokens(payload.comment, rm); + else payload.notes = removeTokens(payload.notes, rm); + } + } const extras: Record = { ...(payload.extras ?? {}) }; const overrides: string[] = []; for (const [code, ref] of Object.entries(byCode)) {