Files
OpsLog/frontend/src/components/ChaseNewPanel.tsx
T
rouggy b70a679c64 feat(chase): the switches follow the operator, not the panel
Chase POTA, US counties, prefixes and grids were written for the cluster
and stayed there, so an operator who does not chase parks still met NEW
POTA in the FT decode list and in Chase new: the same badge withdrawn on
one screen and shouting on the next. The setting is about what is hunted.

One helper answers the question for every panel, keyed by both the status
field names and the shorter category names so a single call serves badges
and filter chips alike. Chase new also stops CATEGORISING a row by a
marker that is switched off, which had left rows listed with no visible
reason for being there. A new US state has no switch of its own, so it is
always allowed.
2026-09-01 16:20:28 +02:00

225 lines
9.0 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 { formatDistance } from '@/lib/units';
import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
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.
//
// A category the operator does not chase is not a category here either: with
// prefixes and squares switched off this panel showed rows whose only reason
// for being listed had been withdrawn everywhere else.
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 && chaseAllows('pfx')) return 'pfx';
if (s.new_grid && chaseAllows('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.filter((c) => chaseAllows(c.key)).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.filter((c) => chaseAllows(c.key)).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 ? formatDistance(s.dist_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>
);
}