feat: progress bar for bulk QSO updates from QRZ.com
Selecting the 102 contacts of a freshly imported contest log and updating them from QRZ.com is 102 network round trips. Nothing moved on screen while it ran, so the only honest reading was that the application had frozen — and the natural response to that is to kill it, halfway through. The backend now emits a progress event per QSO and the frontend shows the same overlay the ADIF import uses: a bar, the count, and the callsign being queried. The callsign is reported BEFORE the lookup rather than after. With a slow provider that is the difference between "waiting on F4BPO" and a name that lags one QSO behind whatever is actually taking the time — which would be the field someone stares at while deciding whether it is stuck. The overlay is cleared by the closing event and again in a finally, so neither a completed run nor a failed one can leave it on screen.
This commit is contained in:
+42
-2
@@ -1518,6 +1518,8 @@ export default function App() {
|
||||
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
||||
const [importApplyCty, setImportApplyCty] = useState(true);
|
||||
const [importApplyStation, setImportApplyStation] = useState(false);
|
||||
// Progress of a bulk update (QRZ.com / cty.dat / ClubLog) over selected QSOs.
|
||||
const [bulkProgress, setBulkProgress] = useState<{ op: string; processed: number; total: number; call: string } | null>(null);
|
||||
// Field remapping, off unless the operator adds a row. Contest software puts
|
||||
// the exchange where its own module keeps it rather than where ADIF says: the
|
||||
// RSGB IOTA contest exports the island reference in STATE, which imports as a
|
||||
@@ -2402,6 +2404,14 @@ export default function App() {
|
||||
const call = String(p?.call ?? '');
|
||||
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||
});
|
||||
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
||||
const total = Number(p?.total ?? 0);
|
||||
const processed = Number(p?.processed ?? 0);
|
||||
// The closing event (processed === total) clears the overlay: the work is
|
||||
// done, and a bar left at 100% would have to be dismissed by hand.
|
||||
if (total > 0 && processed >= total) { setBulkProgress(null); return; }
|
||||
setBulkProgress({ op: String(p?.op ?? ''), processed, total, call: String(p?.call ?? '') });
|
||||
});
|
||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||
});
|
||||
@@ -2428,7 +2438,7 @@ export default function App() {
|
||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -2935,9 +2945,9 @@ export default function App() {
|
||||
}
|
||||
async function bulkUpdateFromQRZ(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}…`);
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
finally { setBulkProgress(null); }
|
||||
}
|
||||
async function bulkUpdateFromClublog(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
@@ -6384,6 +6394,36 @@ export default function App() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{bulkProgress && (
|
||||
<Dialog open>
|
||||
<DialogContent className="max-w-sm px-6" hideClose>
|
||||
<DialogHeader className="px-2">
|
||||
<DialogTitle>{t('bulk.progressTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-2 pb-4 space-y-2">
|
||||
{(() => {
|
||||
const { processed, total, call } = bulkProgress;
|
||||
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
||||
return (
|
||||
<>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full bg-primary rounded-full transition-[width] duration-150', total === 0 && 'w-1/3 animate-pulse')}
|
||||
style={total > 0 ? { width: `${pct}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-mono">{call || '\u00a0'}</span>
|
||||
<span>{total > 0 ? `${processed} / ${total}` : ''}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{importing && (
|
||||
<Dialog open>
|
||||
<DialogContent className="max-w-sm px-6" hideClose>
|
||||
|
||||
Reference in New Issue
Block a user