Files
OpsLog/frontend/src/components/ChaseNewPanel.tsx
T
2026-08-21 23:28:55 +02:00

219 lines
8.6 KiB
TypeScript

// ChaseNewPanel — the stations PSK Reporter is hearing NEAR HERE that are new
// against the log.
//
// A DX cluster tells you what somebody chose to spot. This tells you what is
// actually being decoded in your own region, which is a different and often
// larger set: nobody spots the FT8 caller running ten watts from a rare square.
//
// The one thing an operator has to know, and the reason for the line at the
// bottom: PSK Reporter carries DIGITAL MODES ONLY. An empty panel means nothing
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
import { useEffect, useMemo, useState } from 'react';
import { Radar, Loader2, X } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils';
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
export interface ChaseNewSpot {
call: string;
band: string;
mode: string;
freq_hz?: number;
grid?: string;
country?: string;
cont?: string;
dist_km?: number;
bearing?: number;
status?: string;
new_pfx?: boolean;
new_grid?: boolean;
lotw?: boolean;
at: string;
}
interface Props {
// Tuning the rig to a row is the whole point — a station heard on 14.074 is
// only useful if you can get there in one click.
onPick?: (s: ChaseNewSpot) => void;
onClose?: () => void;
}
// The categories, in priority order. A station can be several at once — a new
// band AND a new prefix — but a row shows ONE, the first that matches here.
//
// The order is by what would make an operator leave what they are doing: a new
// entity beats a new band on it, which beats a new mode, which beats a new slot;
// a prefix or a square is worth knowing but never worth interrupting a QSO for.
// Two badges on one line also made the list unreadable at a glance, which is the
// only thing this panel is for.
type Category = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid';
const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
{ key: 'dxcc', labelKey: 'clg2.newDxcc', colour: 'var(--danger)' },
{ key: 'band', labelKey: 'clg2.newBand', colour: 'var(--danger)' },
{ key: 'mode', labelKey: 'clg2.newMode', colour: 'var(--danger)' },
{ key: 'slot', labelKey: 'clg2.newSlot', colour: 'var(--danger)' },
{ key: 'pfx', labelKey: 'clg2.newPfx', colour: markerColour('new_pfx') },
{ key: 'grid', labelKey: 'clg2.newGrid', colour: markerColour('new_grid') },
];
// categoryOf is the single thing a row says about a station.
function categoryOf(s: ChaseNewSpot): Category | null {
switch (s.status) {
case 'new': return 'dxcc';
case 'new-band-mode': return 'band';
case 'new-band': return 'band';
case 'new-mode': return 'mode';
case 'new-slot': return 'slot';
}
if (s.new_pfx) return 'pfx';
if (s.new_grid) return 'grid';
return null;
}
const FILTER_KEY = 'opslog.chaseNewFilters';
function loadFilters(): Set<Category> {
try {
const raw = localStorage.getItem(FILTER_KEY);
if (raw) {
const list = JSON.parse(raw) as Category[];
if (Array.isArray(list)) return new Set(list);
}
} catch { /* a corrupt preference is not worth a broken panel */ }
return new Set(CATEGORIES.map((c) => c.key));
}
export function ChaseNewPanel({ onPick, onClose }: Props) {
const { t } = useI18n();
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
const [loaded, setLoaded] = useState(false);
const [on, setOn] = useState<Set<Category>>(loadFilters);
// Polled rather than pushed: the feed can deliver several a second under an
// opening, and an event per row would be a redraw per row for a list nobody
// reads that fast.
useEffect(() => {
let alive = true;
const tick = async () => {
try {
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
if (alive) { setSpots(r); setLoaded(true); }
} catch { /* the feed may not be up yet */ }
};
tick();
const id = window.setInterval(tick, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
function toggle(k: Category) {
setOn((prev) => {
const next = new Set(prev);
if (next.has(k)) next.delete(k); else next.add(k);
try { localStorage.setItem(FILTER_KEY, JSON.stringify([...next])); } catch { /* not worth failing over */ }
return next;
});
}
const shown = useMemo(() => {
return spots.filter((s) => {
const c = categoryOf(s);
return c !== null && on.has(c);
});
}, [spots, on]);
return (
<div className="flex h-full flex-col rounded-md border border-border bg-card overflow-hidden">
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
<Radar className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0 text-xs font-semibold">{t('chn.title')}</span>
{/* Filters, in the same order and colours as the badges they hide. */}
<div className="flex flex-1 flex-wrap items-center gap-1">
{CATEGORIES.map((c) => (
<button
key={c.key}
type="button"
onClick={() => toggle(c.key)}
title={t('chn.filterHint')}
className={cn(
'rounded border px-1 py-px text-[9px] font-bold uppercase tracking-wide transition-colors',
on.has(c.key) ? 'border-transparent' : 'border-border text-muted-foreground opacity-50',
)}
style={on.has(c.key) ? { color: c.colour, borderColor: c.colour } : undefined}
>
{t(c.labelKey).replace(/^(NEW|NOUV)\s+/i, '')}
</button>
))}
</div>
<span className="shrink-0 text-[10px] text-muted-foreground">
{loaded ? t('chn.count', { n: shown.length }) : ''}
</span>
{onClose && (
<button
type="button"
onClick={onClose}
title={t('chn.close')}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex-1 overflow-y-auto">
{!loaded ? (
<div className="flex items-center justify-center gap-2 p-3 text-[11px] text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> {t('chn.loading')}
</div>
) : shown.length === 0 ? (
<p className="p-3 text-[11px] text-muted-foreground leading-relaxed">
{spots.length === 0 ? t('chn.empty') : t('chn.allFiltered')}
</p>
) : (
<div className="divide-y divide-border/60">
{shown.map((s, i) => {
const c = categoryOf(s);
const def = CATEGORIES.find((x) => x.key === c);
return (
<button
key={`${s.call}-${s.band}-${s.mode}-${i}`}
type="button"
onClick={() => onPick?.(s)}
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] hover:bg-accent/40"
title={[
s.country,
s.grid,
s.dist_km ? `${s.dist_km} km` : '',
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
].filter(Boolean).join(' · ')}
>
<span className="w-[84px] shrink-0 truncate font-mono font-bold">{s.call}</span>
<span className="w-9 shrink-0 font-mono text-muted-foreground">{s.band}</span>
<span className="w-10 shrink-0 truncate text-muted-foreground">{s.mode}</span>
{/* No frequency column: clicking the row tunes the rig to it,
so the number was a thing to read and never to use. It is
still carried on the spot — the click is what needs it —
and shows in the row's tooltip for anyone who wants it.
The country takes the space it freed. */}
<span className="min-w-0 flex-1 truncate text-muted-foreground">{s.country ?? ''}</span>
{def && (
<span className="shrink-0 text-[9px] font-bold" style={{ color: def.colour }}>
{t(def.labelKey)}
</span>
)}
{s.lotw && <span className="shrink-0 text-[9px] text-info" title="LoTW">L</span>}
</button>
);
})}
</div>
)}
</div>
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
</div>
);
}