up
This commit is contained in:
+71
-25
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user