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:
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user