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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user