This commit is contained in:
2026-05-28 11:09:07 +02:00
parent a8b7622667
commit d3c9982c66
8 changed files with 380 additions and 200 deletions
+71 -25
View File
@@ -353,6 +353,8 @@ export default function App() {
comment?: string;
locator?: string;
time_utc?: string;
country?: string;
continent?: string;
received_at: string;
raw: string;
};
@@ -392,7 +394,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 }>>({});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string }>>({});
// === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
@@ -569,7 +571,24 @@ export default function App() {
}
useEffect(() => {
reloadClusterMeta();
EventsOn('cluster:state', (sts: ServerStatus[]) => setClusterServerStatuses(sts ?? []));
// 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[]) => {
setClusterServerStatuses(sts ?? []);
try {
const list = await ListClusterServers();
setClusterServers(((list ?? []) as any[]).map((s) => ({
id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
})));
} catch {}
// Drop any buffered spots whose source server is no longer in the
// status list (it was disconnected / deleted). Without this the
// table keeps showing stale rows from a server the user just
// turned off.
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) => {
setSpots((arr) => {
const next = [sp, ...arr];
@@ -604,7 +623,7 @@ 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 };
next[k] = { status: r.status ?? '', country: r.country, continent: (r as any).continent };
}
return next;
});
@@ -1791,6 +1810,8 @@ export default function App() {
{ 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' },
@@ -1800,19 +1821,14 @@ export default function App() {
s.key === k
? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' }
: { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' });
const rowColor = (s: ClusterSpot): string => {
// The cache key includes the inferred mode (from
// comment / band-plan) so CW vs FT8 on the same
// band get distinct statuses.
// 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) => {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k];
if (!st) return '';
switch (st.status) {
case 'new': return 'bg-rose-50 hover:bg-rose-100';
case 'new-band': return 'bg-amber-50 hover:bg-amber-100';
case 'new-slot': return 'bg-yellow-50 hover:bg-yellow-100';
default: return '';
}
return spotStatus[k]?.status ?? '';
};
return (
<table className="w-full border-collapse text-[12.5px]">
@@ -1844,10 +1860,22 @@ export default function App() {
</tr>
</thead>
<tbody>
{rendered.map((s, i) => (
{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={cn('cursor-pointer', rowColor(s) || 'hover:bg-accent/30')}
className="cursor-pointer hover:bg-accent/30"
onClick={() => {
// Mode comes from the spot itself (comment text
// first, band plan fallback). Sending it to CAT
@@ -1871,20 +1899,38 @@ export default function App() {
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 font-mono font-bold text-primary whitespace-nowrap border-b border-border/40">{s.dx_call}</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"><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">{(() => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (!m) return <span className="text-muted-foreground text-[10px]"></span>;
return <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono text-[10px] py-0" variant="outline">{m}</Badge>;
})()}</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>
);