feat(entry/stats/awards): Alt+W clear, slot drill-down, DXCC prefix column

Five operator-requested items:

- Alt+W clears the QSO entry. Handled before the `typing` guard and above
  the keyer's key routing, so there is always one key that clears whatever
  else is running — Esc is not that key when the CW keyer reserves it.

- The Grid box no longer pops outside the entry panel on a narrow window.
  Row 2 needed 300+130+76+gaps = 538 px inside a panel whose min-width is
  520, so Grid was pushed out and clipped at every narrow width, not just
  extreme ones. QTH's min-width drops to 80 and the row wraps rather than
  overflowing if it ever still can't fit.

- Selecting a QSO in the log now drives the Stats (F1) matrix. Uses its own
  WorkedBefore call into separate state, NOT runWorkedBefore: that one owns
  the entry form's wbRef and can trigger a field backfill, which browsing
  the log must never do. The entry form wins whenever it holds a call.

- Clicking a coloured band/mode square lists the contacts behind it. Returns
  the exact callsign AND the rest of the entity, because that pair is what
  the cell's colour encodes; the call's own QSOs are bolded. The DXCC arm
  matches the stored dxcc column only — reconstructing it from the callsign
  here would disagree with the matrix above, which is built from that column.

- The Awards DXCC list shows each entity's primary prefix in its own sortable
  column. Derived live from cty.dat into Ref.Group rather than stored on the
  reference row, so an existing installation needs no re-seed.
This commit is contained in:
2026-08-05 13:47:36 +02:00
parent a20d9a0b76
commit a554257d5f
12 changed files with 382 additions and 21 deletions
+45 -3
View File
@@ -1512,6 +1512,11 @@ export default function App() {
const [deletingIds, setDeletingIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// Call/band/mode of the QSO highlighted in the Recent-QSOs grid. Drives the
// Stats (F1) matrix whenever the entry form is empty — clicking a past contact
// is the natural way to ask "what else have I got with this one?", and until
// now the panel just sat blank.
const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string } | null>(null);
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false);
@@ -3315,6 +3320,24 @@ export default function App() {
finally { setDeletingAll(false); }
}
// Worked-before for the QSO highlighted in the grid. Deliberately NOT
// runWorkedBefore: that one owns the entry form's wbRef and can trigger a
// field backfill, which browsing the log must never do.
const [selWb, setSelWb] = useState<WB | null>(null);
const [selWbBusy, setSelWbBusy] = useState(false);
useEffect(() => {
const call = (selQso?.call ?? '').trim().toUpperCase();
// The entry form wins whenever it has a call — it's the live QSO.
if (!call || callsign.trim()) { setSelWb(null); return; }
let dead = false;
setSelWbBusy(true);
WorkedBefore(call, 0)
.then((w: any) => { if (!dead) setSelWb(w); })
.catch(() => { if (!dead) setSelWb(null); })
.finally(() => { if (!dead) setSelWbBusy(false); });
return () => { dead = true; };
}, [selQso, callsign]);
async function runWorkedBefore(call: string, dxccHint: number = 0) {
setWbBusy(true);
try {
@@ -3859,6 +3882,16 @@ export default function App() {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
e.preventDefault(); importAdif(); return;
}
// Alt+W: wipe the entry. The near-universal logger convention (N1MM,
// Log4OM, DXLog), and unlike ESC it is never swallowed by the CW keyer —
// so there is always one key that clears, whatever else is running.
// Alt-combos carry no character on Windows, so this is safe while typing.
if (e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'w') {
e.preventDefault();
resetEntry();
callsignRef.current?.focus();
return;
}
if (typing) return;
if (selectedId !== null) {
if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
@@ -4131,7 +4164,7 @@ export default function App() {
</div>
</>
) : (
<div className="flex flex-col flex-1 min-w-[130px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
<div className="flex flex-col flex-1 min-w-[80px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div>
);
@@ -4412,7 +4445,7 @@ export default function App() {
</Button>
)}
<Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
title="Clear the QSO entry (always unlike Esc which may be reserved for the CW keyer)">
title="Clear the QSO entry — Alt+W (always works, unlike Esc which may be reserved for the CW keyer)">
<Eraser className="size-3.5" />
{t('btn.clear')}
</Button>
@@ -4746,6 +4779,7 @@ export default function App() {
onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
/>
</div>
);
@@ -5483,7 +5517,10 @@ export default function App() {
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
its right edge lines up with that column below; QTH grows to fill.
gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
<div className="flex gap-4 items-end">
{/* flex-wrap, not overflow: at the narrowest window the Grid box used
to be pushed outside the entry panel and clipped. Wrapping drops
it to its own line instead, which stays readable. */}
<div className="flex gap-4 items-end flex-wrap">
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
@@ -5549,6 +5586,10 @@ export default function App() {
band={band}
mode={mode}
bands={bands}
{...(!callsign.trim() && selQso ? {
slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode,
slotWb: selWb, slotWbBusy: selWbBusy,
} : {})}
tab={detailTab}
onTab={setDetailTab}
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
@@ -5999,6 +6040,7 @@ export default function App() {
onExportCabrilloFiltered={exportFilteredCabrillo}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
/>
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
<div className="flex items-center gap-3">