rigs completed

This commit is contained in:
2026-05-28 18:35:22 +02:00
parent d3c9982c66
commit e8cac569e3
26 changed files with 3834 additions and 391 deletions
+170 -274
View File
@@ -19,8 +19,9 @@ import {
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses,
GetCATSettings,
OperatingDefaultForBand,
} from '../wailsjs/go/main/App';
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
import { EventsOn } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -30,8 +31,11 @@ import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandSlotGrid } from '@/components/BandSlotGrid';
import { BandMap } from '@/components/BandMap';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { CallHistoryPanel } from '@/components/CallHistoryPanel';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { Button } from '@/components/ui/button';
@@ -327,6 +331,25 @@ export default function App() {
const updateDetails = useCallback((patch: Partial<DetailsState>) => {
setDetails((d) => ({ ...d, ...patch }));
}, []);
// Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree
// whenever the band changes. The backend resolves the "default antenna
// for this band" within the active profile and returns the (rig,
// antenna) tuple. Empty result → we DO clear the fields so leftover
// values from a previous band don't get logged against the wrong gear.
useEffect(() => {
if (!band) return;
let cancelled = false;
OperatingDefaultForBand(band).then((d) => {
if (cancelled) return;
setDetails((cur) => ({
...cur,
my_rig: d?.station_name || '',
my_antenna: d?.antenna_name || '',
tx_pwr: d?.tx_pwr ?? cur.tx_pwr,
}));
}).catch(() => {});
return () => { cancelled = true; };
}, [band]);
const prefix = useMemo(() => computePrefix(callsign), [callsign]);
// Bearing/distance from operator's home grid to the remote station —
// shown live in the entry strip (SP azimuth) and Info tab (LP + dist).
@@ -340,6 +363,16 @@ export default function App() {
const [filterBand, setFilterBand] = useState('');
const [filterMode, setFilterMode] = useState('');
const [activeTab, setActiveTab] = useState('recent');
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
// huge logs render OK once loaded, but a 25k+ logbook still takes a
// couple of seconds to round-trip from SQLite at launch. Defaulting
// to 500 keeps the first paint instant; the user can bump to "All"
// when they actually want to search history.
const [qsoLimit, setQsoLimit] = useState<number>(() => {
const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500');
return Number.isFinite(raw) && raw > 0 ? raw : 500;
});
useEffect(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
// === DX Cluster live state ===
type ClusterSpot = {
@@ -355,6 +388,11 @@ export default function App() {
time_utc?: string;
country?: string;
continent?: string;
cqz?: number;
ituz?: number;
distance_km?: number;
sp_deg?: number;
lp_deg?: number;
received_at: string;
raw: string;
};
@@ -394,7 +432,7 @@ export default function App() {
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string }>>({});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean }>>({});
// === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
@@ -451,7 +489,7 @@ export default function App() {
try {
const list = await ListQSO({
callsign: filterCallsign, band: filterBand, mode: filterMode,
limit: 500, offset: 0,
limit: qsoLimit, offset: 0,
} as any);
const n = await CountQSO();
setQsos(list);
@@ -460,7 +498,7 @@ export default function App() {
} catch (e: any) {
setError(String(e?.message ?? e));
}
}, [filterCallsign, filterBand, filterMode]);
}, [filterCallsign, filterBand, filterMode, qsoLimit]);
const loadStation = useCallback(async () => {
try { setStation(await GetStationSettings()); } catch {}
@@ -517,7 +555,7 @@ export default function App() {
// CAT live updates. Push freq/band/mode into the entry strip when the rig
// moves, unless the user just typed something (1.5s grace window).
useEffect(() => {
EventsOn('cat:state', (s: CATState) => {
const unsub = EventsOn('cat:state', (s: CATState) => {
setCatState(s);
if (!s?.connected) return;
if (Date.now() < catFreezeUntilRef.current) return;
@@ -554,7 +592,7 @@ export default function App() {
}
}
});
return () => { EventsOff('cat:state'); };
return () => { unsub?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -574,7 +612,7 @@ export default function App() {
// cluster:state fires on connect/disconnect/save/delete — refresh
// the saved-server list too so the source dropdown stays in sync
// when the user adds, deletes or toggles a row in Settings.
EventsOn('cluster:state', async (sts: ServerStatus[]) => {
const unsubState = EventsOn('cluster:state', async (sts: ServerStatus[]) => {
setClusterServerStatuses(sts ?? []);
try {
const list = await ListClusterServers();
@@ -589,13 +627,13 @@ export default function App() {
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
});
EventsOn('cluster:spot', (sp: ClusterSpot) => {
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
setSpots((arr) => {
const next = [sp, ...arr];
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
});
});
return () => { EventsOff('cluster:state'); EventsOff('cluster:spot'); };
return () => { unsubState?.(); unsubSpot?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -623,7 +661,12 @@ export default function App() {
const next = { ...prev };
for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { status: r.status ?? '', country: r.country, continent: (r as any).continent };
next[k] = {
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
};
}
return next;
});
@@ -955,6 +998,7 @@ export default function App() {
return (
<div className="flex flex-col h-screen bg-background">
<ShutdownProgress />
{/* ===== TOPBAR ===== */}
{compact ? (
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
@@ -1127,10 +1171,6 @@ export default function App() {
<Settings className="size-3.5" /> Set station
</Button>
)}
<div className="text-right">
<div className="font-bold text-[15px] leading-none">{total.toLocaleString('en-US')}</div>
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">Total QSOs</div>
</div>
<Button
variant={showBandMap ? 'default' : 'outline'}
size="sm"
@@ -1431,9 +1471,9 @@ export default function App() {
/>
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0', showBandMap ? 'grid-cols-[1fr_360px_260px]' : 'grid-cols-[1fr_360px]')}>
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', showBandMap ? 'grid-cols-[1fr_260px]' : 'grid-cols-[1fr]')}>
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
<TabsList className="px-3 shrink-0">
<TabsTrigger value="main">Main</TabsTrigger>
<TabsTrigger value="recent">
@@ -1441,6 +1481,12 @@ export default function App() {
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{qsos.length}</Badge>
</TabsTrigger>
<TabsTrigger value="cluster">Cluster</TabsTrigger>
<TabsTrigger value="worked">
Worked before
{wb && wb.count > 0 && (
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="propagation">Propagation</TabsTrigger>
</TabsList>
@@ -1515,67 +1561,45 @@ export default function App() {
</div>
)}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse text-[12.5px]">
<thead>
<tr>
{['Date UTC','Callsign','Band','Mode','MHz','RST sent','RST rcvd','Name','QTH','Country','Grid','Station','Comment',''].map((h, i) => (
<th
key={i}
className={cn(
'sticky top-0 z-10 bg-stone-200 px-2.5 py-2 text-left font-semibold text-muted-foreground text-[11px] uppercase tracking-wide border-b border-border whitespace-nowrap',
h === 'MHz' && 'text-right',
i === 13 && 'w-0',
)}
>{h}</th>
))}
</tr>
</thead>
<tbody>
{qsos.length === 0 ? (
<tr><td colSpan={14} className="text-center py-10 text-muted-foreground italic">No QSO yet. Log your first contact above.</td></tr>
) : qsos.map((q, i) => (
<tr
key={q.id}
className={cn(
'cursor-pointer hover:bg-stone-100 transition-colors',
i % 2 === 1 && 'bg-stone-50/60',
selectedId === q.id && '!bg-accent',
)}
onClick={() => setSelectedId(q.id)}
onDoubleClick={() => openEdit(q.id)}
>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{fmtDateUTC(q.qso_date)}</td>
<td className="px-2.5 py-1.5 font-mono font-semibold text-primary whitespace-nowrap border-b border-border/40">{q.callsign}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
<span className="inline-block px-2 py-0.5 rounded font-mono text-[11px] font-semibold bg-accent text-accent-foreground">{q.band}</span>
</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
<span className="inline-block px-2 py-0.5 rounded font-mono text-[11px] font-semibold bg-emerald-100 text-emerald-700">{q.mode}</span>
</td>
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{q.freq_hz ? fmtFreqDots(fmtFreq(q.freq_hz)) : ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_sent ?? ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_rcvd ?? ''}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.name ?? ''}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.qth ?? ''}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.country ?? ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.grid ?? ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.station_callsign ?? ''}</td>
<td className="px-2.5 py-1.5 text-muted-foreground whitespace-nowrap border-b border-border/40 max-w-[200px] overflow-hidden text-ellipsis">{q.comment ?? ''}</td>
<td className="px-1.5 py-0.5 text-right whitespace-nowrap border-b border-border/40 opacity-0 group-hover:opacity-100">
<Button size="icon" variant="ghost" className="size-6 mx-0.5"
onClick={(e) => { e.stopPropagation(); openEdit(q.id); }}>
<Pencil className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6 mx-0.5 hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); askDelete(q.id); }}>
<Trash2 className="size-3" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
<RecentQSOsGrid
rows={qsos as any}
total={total}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onRowSelected={(id) => setSelectedId(id)}
/>
<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">
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{total}</span>
{filterCallsign || filterBand || filterMode ? ' (filtered)' : ''}
</span>
<div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-amber-700">Limit reached raise Max to see more.</span>
)}
<Label className="text-[11px] text-muted-foreground">Max</Label>
<Input
type="number"
min={1}
step={100}
className="w-24 h-7 font-mono text-xs"
value={qsoLimit}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n > 0) setQsoLimit(Math.floor(n));
}}
/>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => setQsoLimit(Math.max(total, 1))}
title="Load the entire log"
disabled={total === 0}
>
All ({total})
</Button>
</div>
</div>
</TabsContent>
@@ -1737,205 +1761,74 @@ export default function App() {
</Select>
</div>
<div className="flex-1 overflow-auto">
{(() => {
// Apply every filter. `bandsActive` is the band set the
// user clicked, OR the entry's locked band when Lock band
// is on. Mode lock compares the spot's inferred mode to
// the entry's current one.
const bandsActive = clusterLockBand
? new Set([band])
: clusterBands;
const search = clusterSearch.trim().toUpperCase();
let list = spots.filter((s) => {
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
if (search && !s.dx_call.includes(search)) return false;
if (clusterLockMode) {
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
// Treat empty inferred mode as wildcard so we don't
// hide perfectly good spots just because the comment
// was ambiguous.
if (spotMode && mode && spotMode !== mode) return false;
}
if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k]?.status || '';
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
}
return true;
});
let rendered = list as (ClusterSpot & { repeats?: number })[];
if (clusterGroup) {
const seen = new Map<string, ClusterSpot & { repeats: number }>();
for (const s of list) {
const e = seen.get(s.dx_call);
if (e) { e.repeats++; }
else seen.set(s.dx_call, { ...s, repeats: 1 });
}
rendered = Array.from(seen.values());
{(() => {
// Apply every filter. `bandsActive` is the band set the
// user clicked, OR the entry's locked band when Lock band
// is on. Mode lock compares the spot's inferred mode to
// the entry's current one.
const bandsActive = clusterLockBand
? new Set([band])
: clusterBands;
const search = clusterSearch.trim().toUpperCase();
let list = spots.filter((s) => {
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
if (search && !s.dx_call.includes(search)) return false;
if (clusterLockMode) {
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
if (spotMode && mode && spotMode !== mode) return false;
}
// Apply sort. Time defaults to descending (newest first).
const dir = clusterSort.dir === 'asc' ? 1 : -1;
const cmp = (a: any, b: any) => (a < b ? -dir : a > b ? dir : 0);
rendered = [...rendered].sort((a, b) => {
switch (clusterSort.key) {
case 'time': return cmp(a.received_at, b.received_at);
case 'call': return cmp(a.dx_call, b.dx_call);
case 'freq': return cmp(a.freq_khz, b.freq_khz);
case 'band': return cmp(a.band ?? '', b.band ?? '');
case 'mode': return cmp(inferSpotMode(a.comment ?? '', a.freq_hz), inferSpotMode(b.comment ?? '', b.freq_hz));
case 'spotter': return cmp(cleanSpotter(a.spotter), cleanSpotter(b.spotter));
case 'source': return cmp(a.source_name, b.source_name);
}
});
if (rendered.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70">
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
</div>
<div className="text-xs">
{clusterServerStatuses.some((s) => s.state === 'connected')
? 'Spots will appear as the cluster sends them.'
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
</div>
</div>
);
}
const headers: { key: SortKey | null; label: string; align?: 'right' }[] = [
{ key: 'time', label: 'Time' },
{ key: 'call', label: 'Call' },
{ key: 'freq', label: 'Freq', align: 'right' },
{ key: 'band', label: 'Band' },
{ key: 'mode', label: 'Mode' },
{ key: null, label: 'Country' },
{ key: null, label: 'Cont' },
{ key: 'spotter', label: 'Spotter' },
{ key: 'source', label: 'Source' },
{ key: null, label: 'Loc' },
{ key: null, label: 'Comment' },
];
const toggleSort = (k: SortKey) => setClusterSort((s) =>
s.key === k
? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' }
: { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' });
// Log4OM-style per-cell highlight: the badge that matches
// the "what's new" gets coloured instead of the whole row.
// CALL = new entity, BAND = new band for entity, MODE = new
// mode for that band (NEW SLOT — Log4OM doesn't show this
// but the user wants it).
const cellHL = (s: ClusterSpot) => {
if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
return spotStatus[k]?.status ?? '';
};
const st = spotStatus[k]?.status || '';
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
}
return true;
});
let rendered = list as (ClusterSpot & { repeats?: number })[];
if (clusterGroup) {
const seen = new Map<string, ClusterSpot & { repeats: number }>();
for (const s of list) {
const e = seen.get(s.dx_call);
if (e) { e.repeats++; }
else seen.set(s.dx_call, { ...s, repeats: 1 });
}
rendered = Array.from(seen.values());
}
if (rendered.length === 0) {
return (
<table className="w-full border-collapse text-[12.5px]">
<thead>
<tr>
{headers.map((h, i) => {
const sortable = h.key !== null;
const active = sortable && clusterSort.key === h.key;
return (
<th
key={i}
onClick={sortable ? () => toggleSort(h.key as SortKey) : undefined}
className={cn(
'px-2.5 py-1.5 font-semibold text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border sticky top-0',
h.align === 'right' ? 'text-right' : 'text-left',
sortable && 'cursor-pointer select-none hover:text-foreground',
active && 'text-primary',
)}
>
{h.label}
{active && (
<span className="ml-1 inline-block text-[9px]">
{clusterSort.dir === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rendered.map((s, i) => {
const hl = cellHL(s);
const callCls = hl === 'new'
? 'bg-rose-100 text-rose-800 hover:bg-rose-200 border border-rose-300'
: 'text-primary';
const bandCls = hl === 'new-band'
? 'bg-amber-200 text-amber-900 border-amber-500 hover:bg-amber-200'
: '';
const modeMode = inferSpotMode(s.comment ?? '', s.freq_hz);
const modeCls = hl === 'new-slot'
? 'bg-yellow-200 text-yellow-900 border-yellow-500 hover:bg-yellow-200'
: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100';
return (
<tr
key={`${s.received_at}-${s.dx_call}-${i}`}
className="cursor-pointer hover:bg-accent/30"
onClick={() => {
// Mode comes from the spot itself (comment text
// first, band plan fallback). Sending it to CAT
// matters because skipping it leaves the rig
// on whatever it had — typically DIGU after a
// previous FT8 contact, which breaks a SSB click.
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
title={s.raw}
>
<td
className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40"
title={s.repeats && s.repeats > 1 ? `Seen ${s.repeats}× across active clusters` : undefined}
>{s.time_utc || ''}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
<span
className={cn('font-mono font-bold inline-block px-1 rounded', callCls)}
title={hl === 'new' ? `NEW DXCC: ${spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''}` : undefined}
>
{s.dx_call}
</span>
</td>
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{s.freq_khz.toFixed(1)}</td>
<td className="px-2.5 py-1.5 border-b border-border/40">
{bandCls
? <Badge className={cn('font-mono text-[10px] py-0', bandCls)} variant="outline" title="NEW BAND for this entity">{s.band || '—'}</Badge>
: <Badge variant="accent" className="font-mono text-[10px] py-0">{s.band || '—'}</Badge>}
</td>
<td className="px-2.5 py-1.5 border-b border-border/40">
{!modeMode
? <span className="text-muted-foreground text-[10px]"></span>
: <Badge className={cn('font-mono text-[10px] py-0', modeCls)} variant="outline" title={hl === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined}>{modeMode}</Badge>}
</td>
<td className="px-2.5 py-1.5 text-[12px] text-muted-foreground whitespace-nowrap border-b border-border/40">
{s.country ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''}
</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground text-[10px] whitespace-nowrap border-b border-border/40">
{s.continent ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.continent ?? ''}
</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{cleanSpotter(s.spotter)}</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground/60 text-[10px] whitespace-nowrap border-b border-border/40">{s.source_name}</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{s.locator || ''}</td>
<td className="px-2.5 py-1.5 text-muted-foreground border-b border-border/40">{s.comment}</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70">
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
</div>
<div className="text-xs">
{clusterServerStatuses.some((s) => s.state === 'connected')
? 'Spots will appear as the cluster sends them.'
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
</div>
</div>
);
})()}
</div>
}
return (
<ClusterGrid
rows={rendered as any}
spotStatus={spotStatus}
onSpotClick={(s) => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
/>
);
})()}
{/* Command input — sends to the master server. */}
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
@@ -1971,6 +1864,10 @@ export default function App() {
now in the topbar, visible on every tab. */}
</TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} />
</TabsContent>
{(['main','awards','propagation'] as const).map((t) => (
<TabsContent key={t} value={t} className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
@@ -1981,7 +1878,6 @@ export default function App() {
</Tabs>
</section>
<CallHistoryPanel wb={wb} busy={wbBusy} currentCall={callsign} />
{showBandMap && (
<div className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<BandMap