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
+123 -4
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { Star, Radio, Sunrise, Sunset } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun';
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView;
@@ -18,6 +19,9 @@ interface Props {
// to an entity with no position at all, and the block simply does not appear.
lat?: number;
lon?: number;
// Set when the matrix is showing a QSO picked in the log grid rather than the
// entry form — labelled so the two can never be confused.
forCall?: string;
}
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
@@ -89,7 +93,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon }: Props) {
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall }: Props) {
// Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// Columns from the operator's configured bands (so the matrix shows only the
// bands they actually use), falling back to the built-in default set.
const cols = useMemo(
@@ -208,6 +214,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</>
) : hasDxcc ? (
<>
{/* Says WHOSE stats these are when they come from a row picked in
the log rather than from what's being typed. */}
{forCall && (
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
title="Stats for the QSO selected in the log">
{forCall}
</Badge>
)}
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`}
</Badge>
@@ -282,10 +296,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return (
<td
key={b.tag}
title={cellTitle(b.tag, cls, st, isCurrent)}
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-mx-none',
// Only a filled cell has anything to show — an empty one
// stays inert rather than opening a "no QSOs" dialog.
st && 'cursor-pointer hover:brightness-110',
isCurrent && 'ring-2 ring-warning ring-inset',
)}
/>
@@ -313,6 +331,107 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
))}
</div>
</div>
{slot && (
<SlotQSOModal
call={wb?.callsign ?? ''}
dxcc={dxcc}
entity={dxccName}
band={slot.band}
cls={slot.cls}
onClose={() => setSlot(null)}
/>
)}
</section>
);
}
// ── Cell drill-down ──────────────────────────────────────────────────────────
// The contacts behind one band+class cell: this exact callsign AND anyone else
// in the entity, because that is the pair of facts the cell's colour encodes.
// The call's own QSOs are marked so the two never blur together.
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose }: {
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void;
}) {
const [rows, setRows] = useState<any[] | null>(null);
const [err, setErr] = useState('');
useEffect(() => {
let dead = false;
BandSlotQSOs(call, dxcc, band, cls)
.then((r: any) => { if (!dead) setRows((r ?? []) as any[]); })
.catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } });
return () => { dead = true; };
}, [call, dxcc, band, cls]);
useEffect(() => {
// Capture phase: the app's global ESC handler resets the entry form, and
// closing a dialog must not also wipe what the operator was typing.
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); }
}
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[720px] max-w-[92vw] max-h-[70vh] flex flex-col"
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<span className="font-semibold text-sm">
{band} · {cls}
{entity && <span className="text-muted-foreground font-normal"> {entity}</span>}
</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{rows ? `${rows.length} QSO` : ''}
</span>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="size-4" />
</button>
</div>
<div className="flex-1 overflow-auto">
{err && <p className="p-3 text-xs text-destructive">{err}</p>}
{!rows ? (
<p className="p-4 text-xs text-muted-foreground flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" /> Loading
</p>
) : rows.length === 0 ? (
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/60 backdrop-blur text-muted-foreground">
<tr>
<th className="text-left font-medium px-2 py-1">Date UTC</th>
<th className="text-left font-medium px-2 py-1">Callsign</th>
<th className="text-left font-medium px-2 py-1">Mode</th>
<th className="text-left font-medium px-2 py-1">Freq</th>
<th className="text-left font-medium px-2 py-1">Name</th>
<th className="text-left font-medium px-2 py-1">Cfm</th>
</tr>
</thead>
<tbody>
{rows.map((q, i) => {
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
return (
<tr key={q.id ?? i} className="border-t border-border/40">
<td className="px-2 py-1 tabular-nums text-muted-foreground whitespace-nowrap">
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
</td>
<td className={cn('px-2 py-1 font-mono', q.callsign === call && 'font-bold text-primary')}>{q.callsign}</td>
<td className="px-2 py-1">{q.mode}</td>
<td className="px-2 py-1 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
<td className="px-2 py-1 text-muted-foreground truncate max-w-[160px]">{q.name}</td>
<td className="px-2 py-1">{cfm ? <span className="text-success"></span> : ''}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}