feat: ClubLog Most Wanted rank in the entry band matrix

Opt-in (Settings → General). Shows a "MW #rank" pill next to the DXCC entity
name in the entry-strip band/slot matrix — ClubLog's Most Wanted ranking
(1 = most wanted), personalised to the operator's callsign and refreshed daily.

- internal/clublog/mostwanted.go: fetch mostwanted.php?api=1&callsign=<CALL>
  (rank→DXCC JSON, real User-Agent), invert to dxcc→rank, cache to
  <dataDir>/clublog_mostwanted.json; NeedsRefresh invalidates on callsign change.
- app.go: keyClublogMostWanted setting, a.clublogMW, startup refresh goroutine,
  activeCallsign() helper, and Get/Set/Download bindings mirroring the cty ones.
  WorkedBefore now carries MWRank (qso.WorkedBefore.MWRank) when enabled.
- BandSlotGrid.tsx: MW pill next to the entity name, colour-tiered by rank.
- SettingsModal General: toggle + download button + status, mirroring the
  ClubLog cty-exceptions block.

Also reorganises the changelog: the theme-persistence fix (landed after the
v0.20.11 tag) plus this feature go under a new 0.20.12 entry; 0.20.11 keeps only
what shipped in its binary.
This commit is contained in:
2026-07-23 20:01:28 +02:00
parent 5c4f101402
commit a71e48f811
9 changed files with 418 additions and 3 deletions
+17
View File
@@ -94,6 +94,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const dxcc = wb?.dxcc ?? 0;
const dxccName = wb?.dxcc_name ?? '';
const dxccCount = wb?.dxcc_count ?? 0;
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
const hasDxcc = dxcc > 0;
const newOne = hasDxcc && dxccCount === 0;
@@ -136,6 +137,20 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = slotNew && bandWorked && modeWorked;
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
// is on). Hotter colour the more wanted the entity is.
const mwBadge = mwRank > 0 ? (
<Badge
title={`ClubLog Most Wanted #${mwRank}`}
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
mwRank <= 100 ? 'bg-danger text-danger-foreground'
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
: 'bg-muted text-muted-foreground')}
>
MW #{mwRank}
</Badge>
) : null;
return (
<section
className={cn(
@@ -154,12 +169,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
{' '}· never worked this entity
</span>
{mwBadge}
</>
) : hasDxcc ? (
<>
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`}
</Badge>
{mwBadge}
<span className="text-xs text-muted-foreground">
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
QSO{dxccCount > 1 ? 's' : ''} with this entity
+50
View File
@@ -17,6 +17,7 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
GetSecretStatus, SetPassphrase, RemovePassphrase,
GetEmailSettings, SaveEmailSettings, TestEmail,
QSLGetEmailTemplates, QSLSaveEmailTemplates,
@@ -1183,6 +1184,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [clubBusy, setClubBusy] = useState(false);
const [clubErr, setClubErr] = useState('');
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
// ClubLog Most Wanted (rank shown in the entry matrix).
type MWInfo = { enabled: boolean; loaded: boolean; callsign: string; date: string; count: number };
const [mwInfo, setMwInfo] = useState<MWInfo>({ enabled: false, loaded: false, callsign: '', date: '', count: 0 });
const [mwBusy, setMwBusy] = useState(false);
const [mwErr, setMwErr] = useState('');
useEffect(() => { GetClublogMostWantedInfo().then((i) => setMwInfo(i as MWInfo)).catch(() => {}); }, []);
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
useEffect(() => {
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
@@ -4781,6 +4788,49 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
</div>
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">ClubLog Most Wanted</h4>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={mwInfo.enabled}
onCheckedChange={async (c) => {
const v = !!c; setMwInfo((s) => ({ ...s, enabled: v })); setMwErr('');
try {
await SetClublogMostWantedEnabled(v);
let info = (await GetClublogMostWantedInfo()) as MWInfo;
// First enable with no cached list → fetch it now (for the active call).
if (v && !info.loaded) {
setMwBusy(true);
try { info = (await DownloadClublogMostWanted()) as MWInfo; }
finally { setMwBusy(false); }
}
setMwInfo(info);
} catch (e: any) { setMwErr(String(e?.message ?? e)); }
}}
className="mt-0.5"
/>
<span>
Show ClubLog "Most Wanted" rank in the entry matrix
<span className="block text-xs text-muted-foreground mt-0.5">
Displays a <strong>MW #rank</strong> pill next to the entity name (1 = the most wanted DXCC entity), fetched
from ClubLog and <strong>personalised to your callsign</strong>. Refreshed daily.
</span>
</span>
</label>
<div className="flex items-center gap-3 pl-6">
<Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
onClick={() => { setMwBusy(true); setMwErr(''); DownloadClublogMostWanted().then((i) => setMwInfo(i as MWInfo)).catch((e: any) => setMwErr(String(e?.message ?? e))).finally(() => setMwBusy(false)); }}>
{mwBusy ? 'Downloading' : (mwInfo.loaded ? 'Update Most Wanted' : 'Download Most Wanted')}
</Button>
<span className="text-[11px] text-muted-foreground">
{mwInfo.loaded
? `${mwInfo.count.toLocaleString()} entities${mwInfo.callsign ? ' · ' + mwInfo.callsign : ''}${mwInfo.date ? ' · ' + mwInfo.date : ''}`
: 'not downloaded'}
</span>
</div>
{mwErr && <p className="text-[11px] text-destructive pl-6">{mwErr}</p>}
</div>
</div>
</>
);