feat(counties): right-click to re-derive a US county from the ULS database

The automatic pass fills a blank county only, and deliberately: a value the
operator or QRZ supplied usually beats one derived from a ZIP code. That leaves
no way at all to correct a county already stored — and the Connecticut planning
regions are exactly that case. Every CT contact logged before that correction
holds a county the state abolished in 2022, and filling blanks never reaches
one of them.

So the new entry OVERWRITES, because it is asked for by hand on a chosen set of
rows precisely because the stored value is believed wrong. Only US entities are
touched, only callsigns the database holds, and only when the answer actually
differs — so re-running it on a mixed selection is safe and the count reported
is the number of counties that really changed. Award refs are re-materialised:
the county IS the reference for CQ USA-CA and the state for WAS.

The entry appears only once the database has been downloaded, and appears
without a restart when one finishes — an action that can only ever answer "no
database" is not worth a line in a menu this long.
This commit is contained in:
2026-08-16 10:45:21 +02:00
parent 5770c40d89
commit 3ed2d957e5
10 changed files with 123 additions and 15 deletions
@@ -49,6 +49,7 @@ type QSOMenuHandlers = {
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onUpdateCountyFromULS?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
+15 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2, MapPin } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
@@ -10,6 +10,9 @@ type Props = {
onUpdateFromCty: (ids: number[]) => void;
onUpdateFromQRZ: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
// Only passed once the offline US county database has been downloaded —
// an entry that can only ever answer "no database" is not worth a line here.
onUpdateCountyFromULS?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
@@ -36,7 +39,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
// which used to dismiss the menu the instant it appeared.)
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
const { t } = useI18n();
const boxRef = useRef<HTMLDivElement>(null);
// Starts at the cursor; the layout effect corrects it once the real size is
@@ -118,6 +121,16 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
<span>{t('qctx.updateClublog')}</span>
</button>
)}
{onUpdateCountyFromULS && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateCountyFromULS(menu.ids); onClose(); }}
title={t('qctx.updateUlsCountyTitle')}
>
<MapPin className="size-4 text-info" />
<span>{t('qctx.updateUlsCounty')}</span>
</button>
)}
{(onSendRecording || onSendEQSL) && (
<>
+3 -1
View File
@@ -64,6 +64,7 @@ type Props = {
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onUpdateCountyFromULS?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
@@ -308,7 +309,7 @@ const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
return rest;
});
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -716,6 +717,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
onUpdateFromClublog={onUpdateFromClublog}
onUpdateCountyFromULS={onUpdateCountyFromULS}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
+3 -1
View File
@@ -35,6 +35,7 @@ type Props = {
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onUpdateCountyFromULS?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
@@ -57,7 +58,7 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -272,6 +273,7 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
onUpdateFromClublog={onUpdateFromClublog}
onUpdateCountyFromULS={onUpdateCountyFromULS}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}