feat: docked watch-list panel, and auto-call learns the orthogonal markers

The watch list was a tab, and an operator working FT8 lives on the decodes
one: a station they had asked to be told about turned up on a screen they
were not looking at. The same answer is now docked in the widget strip,
above the tabs, reduced to what is worth acting on — on the air and still
needed, one row per band and mode, with the cluster's own NEW DXCC /
NEW BAND / NEW SLOT badge and a click that tunes. Off by default. The
"active and needed" answer costs a debounced query per visible slot, so it
is written once (lib/watchlistSpots) and the tab uses it too.

Auto-call:

- It answers a new prefix, county, state, square or park. Those markers
  are orthogonal to the entity, they ranked as nothing-needed, and the
  engine sat through a never-worked WPX prefix calling CQ. New rung at
  the foot of the ladder, gated by the chase switches the badges use —
  which meant making those switches portable, since the backend cannot
  read localStorage.
- It calls THROUGH a pileup. Giving up the moment the DX answered
  somebody else is precisely how a queue is not worked; the call and miss
  counters already bound the effort, and a station in mid-exchange is
  still never chosen as a new target.

The PSK Reporter panel now follows the station auto-call is waiting for:
the analysis takes a history query and a period or two to fill, so
starting it when the DX comes free is starting it too late.

Callbook lookup: a compound callsign with a page of its OWN keeps that
page's location. QRZ files HP/WE9G under exactly that form, with the
Panama square the station is operating from, and the rule that drops a
home address from a portable call was throwing it away. The record's own
country tells an operation's page from a home page.

Changelog: entries may open with [NEW], drawn as a pill in the What's new
dialog — a release is mostly fixes and the two or three genuinely new
things should not have to be found by reading all of it.
This commit is contained in:
2026-09-06 00:08:55 +02:00
parent 23323c91e0
commit 03e71bfdf2
15 changed files with 733 additions and 228 deletions
+2 -2
View File
@@ -304,13 +304,13 @@ export function AppearancePanel() {
// place here: it comes back where the operator left it rather than at the end.
export const WIDGET_KEYS = [
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
'amp', 'tuner', 'scp', 'chasenew', 'dvk', 'winkeyer', 'photo',
'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
] as const;
const WIDGET_LABELS: Record<string, string> = {
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew',
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
};
+9 -94
View File
@@ -25,13 +25,7 @@ import {
import { EventsOn } from '../../wailsjs/runtime/runtime';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
interface WLEntry {
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
isContest: boolean; notify: boolean;
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
}
import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots';
interface Props {
spots: ClusterSpot[];
@@ -40,21 +34,6 @@ interface Props {
onSpotClick?: (s: ClusterSpot) => void;
}
// A spot is ON AIR for the badge while its last sighting is this fresh.
const ON_AIR_MS = 10 * 60 * 1000;
// Exact unless the entry carries a trailing * — the same rule the backend's
// Match applies to the live stream, mirrored so the tab and the alerts can
// never disagree about what an entry covers.
function matchesEntry(call: string, pattern: string): boolean {
const c = call.toUpperCase();
if (pattern.endsWith('*')) {
const p = pattern.slice(0, -1);
return p !== '' && c.startsWith(p);
}
return c === pattern;
}
export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
const { t } = useI18n();
const [entries, setEntries] = useState<WLEntry[]>([]);
@@ -95,8 +74,6 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
};
// worked answer per "call|band|modeclass|contest" key.
const [worked, setWorked] = useState<Record<string, boolean>>({});
const refresh = useCallback(async () => {
try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); }
@@ -117,63 +94,11 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// Live spots per entry — prefix-matched, newest first, deduped per band+mode
// (one line per slot; the freshest spot represents it).
const spotsFor = useMemo(() => {
const map = new Map<string, ClusterSpot[]>();
for (const e of entries) map.set(e.callsign, []);
for (const s of spots) {
for (const e of entries) {
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
}
}
for (const [k, list] of map) {
const seen = new Set<string>();
map.set(k, list.filter((s) => {
const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}));
}
return map;
}, [spots, entries]);
// The worked answers, refreshed when the visible slots change. Debounced: a
// spot burst must cost one round trip, not one per spot.
const queryTimer = useRef<number | undefined>(undefined);
useEffect(() => {
if (queryTimer.current) window.clearTimeout(queryTimer.current);
queryTimer.current = window.setTimeout(async () => {
const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
const keys: string[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
}
}
if (queries.length === 0) { setWorked({}); return; }
try {
const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
// MERGED, not replaced: replacing made every already-answered key
// momentarily unknown on each refresh, which re-hid settled lines.
setWorked((prev) => {
const next = { ...prev };
keys.forEach((k, i) => { next[k] = !!res[i]; });
return next;
});
} catch { /* the badges just stay conservative */ }
}, 150) as unknown as number;
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
}, [spotsFor, entries]);
const wkey = (e: WLEntry, s: ClusterSpot) =>
`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
const workedFor = (e: WLEntry, s: ClusterSpot): boolean => worked[wkey(e, s)] ?? false;
// A spot whose verdict has not come back yet is NOT drawn. Showing it as
// Needed and withdrawing it half a second later made the list twitch on
// every burst — and nobody needs a spot 400 ms early, they need it settled.
const settled = (e: WLEntry, s: ClusterSpot): boolean => wkey(e, s) in worked;
// Which entries are on the air, and which of their slots are still needed.
// One definition, shared with the docked watch-list widget — the answer
// involves a debounced query per visible slot, and two copies of it would be
// two bursts of the same question and two ideas of what "needed" means.
const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots);
const add = async () => {
const c = addCall.trim().toUpperCase();
@@ -195,11 +120,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
catch (e: any) { flash(String(e?.message ?? e), true); }
};
const isOnAir = (e: WLEntry): boolean =>
(spotsFor.get(e.callsign) ?? []).some((s) => {
const ts = Date.parse(String((s as any).received_at ?? ''));
return ts > 0 && Date.now() - ts < ON_AIR_MS;
});
const isOnAir = onAir;
const shown = entries.filter((e) => {
if (family === 'normal' && e.isContest) return false;
@@ -225,14 +146,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// The cluster's own badge for a spot, read from the shared status index.
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
switch (st?.status) {
case 'new': return { label: t('wl.newDxcc'), color: 'var(--danger)' };
case 'new-band-mode': return { label: t('clg2.newBandMode'), color: 'var(--danger)' };
case 'new-band': return { label: t('clg2.newBand'), color: 'var(--warning)' };
case 'new-mode': return { label: t('clg2.newMode'), color: 'var(--caution)' };
case 'new-slot': return { label: t('clg2.newSlot'), color: '#5AC8FA' };
default: return null;
}
const b = newBadge(st?.status);
return b ? { label: t(b.key), color: b.colour } : null;
};
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
+152
View File
@@ -0,0 +1,152 @@
// WatchlistWidget — the watch list reduced to what is worth acting on RIGHT
// NOW: entries that are on the air and still needed.
//
// The Watchlist tab is a tab, and an operator working FT8 lives on the decodes
// one. A station they asked to be told about would appear on a screen they are
// not looking at — so the same answer is docked in the widget strip, which sits
// above the tabs and is therefore always in view. Only active AND needed: a
// list of everything watched is the tab's job, and it would not fit here.
import { useEffect, useMemo, useState } from 'react';
import { Bell, X } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
import { useWatchlistSpots, newBadge, type WLEntry } from '@/lib/watchlistSpots';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { WatchlistEntries } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
interface Props {
spots: ClusterSpot[];
// The cluster's own verdicts. WHAT a station is worth is the reason to leave
// what you are doing for it — "on the air and not worked" says nothing about
// whether it is a new entity or a fifth band on one worked in 1998.
spotStatus: Record<string, SpotStatusEntry>;
// A row is a spot: clicking it does what clicking one in the cluster does.
onPick?: (s: ClusterSpot) => void;
onClose?: () => void;
}
function age(s: ClusterSpot): string {
const ts = Date.parse(String((s as any).received_at ?? ''));
if (!(ts > 0)) return '';
const m = Math.floor((Date.now() - ts) / 60000);
if (m < 1) return 'now';
return `${m}m`;
}
export function WatchlistWidget({ spots, spotStatus, onPick, onClose }: Props) {
const { t } = useI18n();
const [entries, setEntries] = useState<WLEntry[]>([]);
const load = () => { WatchlistEntries().then((e: any) => setEntries((e ?? []) as WLEntry[])).catch(() => {}); };
useEffect(() => {
load();
// The list changes from four places (the tab, the cluster's star, the
// contest auto-add, an import), and a widget that only reads it at mount
// would quietly watch the wrong set for the rest of the session.
const off = EventsOn('watchlist:changed', load);
return () => { off?.(); };
}, []);
const { spotsFor, workedFor, settled } = useWatchlistSpots(entries, spots);
// Ticking, because every row carries an age and the freshest thing here is
// the reason to look at it at all.
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((n) => n + 1), 30000);
return () => window.clearInterval(id);
}, []);
// One row per (entry, needed slot): the same station on two bands is two
// chances to work it, and collapsing them would hide the one that is open.
const rows = useMemo(() => {
const out: { e: WLEntry; s: ClusterSpot }[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
if (!settled(e, s) || workedFor(e, s)) continue;
out.push({ e, s });
}
}
// Freshest first: a spot from twenty minutes ago is history, and this
// panel is short.
return out.sort((a, b) =>
Date.parse(String((b.s as any).received_at ?? '')) - Date.parse(String((a.s as any).received_at ?? '')));
}, [entries, spotsFor, workedFor, settled]);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Bell className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('wlw.title')}
</span>
<span className={cn('rounded px-1.5 py-px text-[10px] font-bold',
rows.length > 0 ? 'bg-warning text-warning-foreground' : 'bg-muted text-muted-foreground')}>
{rows.length}
</span>
<div className="flex-1" />
{onClose && (
<button type="button" onClick={onClose} title={t('wlw.hide')}
className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{rows.length === 0 ? (
// Empty is the normal state, and it means something precise — say it,
// rather than leaving a blank box that reads as broken.
<div className="px-3 py-3 text-xs text-muted-foreground">
{entries.length === 0 ? t('wlw.emptyList') : t('wlw.emptyNone')}
</div>
) : rows.map(({ e, s }, i) => {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
const badge = newBadge(spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.status);
return (
<button
key={`${e.callsign}-${s.dx_call}-${s.band}-${mode}-${i}`}
type="button"
onClick={() => onPick?.(s)}
title={t('wlw.pick', { call: s.dx_call })}
className="w-full text-left px-2 py-1 border-b border-border/20 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[13px] font-bold text-warning truncate">{s.dx_call}</span>
{/* The entry it matched, when it is a prefix: "DP1*" told the
operator to watch a fleet, and which member turned up is not
obvious from the callsign alone. */}
{e.callsign !== s.dx_call && (
<span className="text-[9px] text-muted-foreground shrink-0">{e.callsign}</span>
)}
<div className="flex-1" />
<span className="font-mono text-[10px] text-muted-foreground/70 tabular-nums shrink-0">
{age(s)}
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{s.band && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-info-muted text-info-muted-foreground shrink-0">
{s.band}
</span>
)}
{mode && <span className="font-mono text-[10px] text-muted-foreground shrink-0">{mode}</span>}
<div className="flex-1" />
{/* Why it is worth interrupting a QSO for — or not. */}
{badge && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide border shrink-0"
style={{ color: badge.colour, borderColor: `color-mix(in srgb, ${badge.colour} 45%, transparent)`,
background: `color-mix(in srgb, ${badge.colour} 12%, transparent)` }}>
{t(badge.key)}
</span>
)}
</div>
</button>
);
})}
</div>
</section>
);
}