feat(rda): measure the two district sources before arbitrating between them

A log imported from HAMLOG.online carries the Russian district in CNTY, and the
offline RDA database has its own answer for the same contact. Which wins is a
real question — but only if the two are independent. If HAMLOG's value is itself
a lookup in the same kind of database, there is nothing to arbitrate and a
precedence rule would be ceremony around a redundancy.

So this measures instead of deciding: Settings → RDA gains a read-only compare
that reports agreements, disagreements, and the contacts where only one side
knows, then lists the disagreements — callsign, date, both districts, whether
the database answer was a DATED record (the award administrators' own fact) or
the callsign's current district (an assumption), and whether the QSO is
confirmed on HAMLOG (their value has then been through a match of two logs).

It changes nothing in the log. What to do about a conflict is the next decision,
and it should be taken with the operator's own numbers in hand.

Also: the four HAMLOG fields become filterable, so 'never sent there' is a
filter rather than a guess. A field that can be written and not read back is
half a feature — pinned by a test against the bulk-editable list.
This commit is contained in:
2026-08-23 14:30:48 +02:00
parent f4a72da118
commit b3e398b8d6
10 changed files with 335 additions and 7 deletions
+65 -1
View File
@@ -13,7 +13,7 @@ import {
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetPSUSettings, SavePSUSettings,
@@ -1854,6 +1854,20 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [rdaUseCurrent, setRdaUseCurrent] = useState(true);
const [rdaMsg, setRdaMsg] = useState<string | null>(null);
useEffect(() => { RDADatabaseCount().then((n) => setRdaCount(Number(n) || 0)).catch(() => {}); }, []);
// Measuring the two sources against each other, before deciding which wins.
//
// Read-only on purpose: the question "is HAMLOG's district independent
// evidence or a copy of the same database?" has to be answered with the
// operator's own log, and answering it must not modify that log.
const [rdaCmp, setRdaCmp] = useState<any>(null);
const [rdaCmpBusy, setRdaCmpBusy] = useState(false);
const runRDACompare = async () => {
setRdaCmpBusy(true); setRdaCmp(null);
try { setRdaCmp(await CompareRDASources()); }
catch (e: any) { setRdaMsg(String(e?.message ?? e)); }
finally { setRdaCmpBusy(false); }
};
const runRDABackfill = async () => {
setRdaBusy(true); setRdaMsg(null);
try {
@@ -7220,6 +7234,56 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<p className="text-[11px] text-muted-foreground">{t('rda.neverOverwrites')}</p>
</div>
{/* Comparing the two sources. Read-only: it answers a question about the
log without touching it. */}
<div className="rounded-md border border-border p-3 space-y-2">
<div className="text-xs font-medium">{t('rda.cmpTitle')}</div>
<div className="flex items-center gap-3">
<Button size="sm" variant="secondary" onClick={runRDACompare} disabled={rdaCmpBusy}>
{rdaCmpBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
{t('rda.cmpRun')}
</Button>
{rdaCmp && (
<span className="text-xs text-muted-foreground">
{t('rda.cmpDone', {
s: rdaCmp.scanned ?? 0, a: rdaCmp.agree ?? 0, d: rdaCmp.disagree ?? 0,
l: rdaCmp.only_log ?? 0, b: rdaCmp.only_db ?? 0,
})}
</span>
)}
</div>
{/* The disagreements themselves. A count alone would say "there is a
problem" and leave the operator with no way to look at it. */}
{rdaCmp?.conflicts?.length > 0 && (
<div className="max-h-56 overflow-auto rounded border border-border">
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr>
<th className="py-1 px-2 font-medium">{t('rda.cmpCall')}</th>
<th className="py-1 pr-2 font-medium">{t('rda.cmpDate')}</th>
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromLog')}</th>
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromDb')}</th>
<th className="py-1 pr-2 font-medium">{t('rda.cmpKind')}</th>
</tr>
</thead>
<tbody>
{rdaCmp.conflicts.map((c: any) => (
<tr key={c.qso_id} className="border-b border-border/30">
<td className="py-1 px-2 font-mono font-semibold">{c.callsign}</td>
<td className="py-1 pr-2 font-mono">{c.date}</td>
<td className="py-1 pr-2 font-mono">{c.from_log}{c.confirmed ? ' ✓' : ''}</td>
<td className="py-1 pr-2 font-mono">{c.from_db}</td>
<td className="py-1 pr-2 text-muted-foreground">
{c.dated ? t('rda.cmpDated') : t('rda.cmpCurrent')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}