rigs completed
This commit is contained in:
+170
-274
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user