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.
134 lines
5.8 KiB
TypeScript
134 lines
5.8 KiB
TypeScript
// What the watch list is HEARING, and what of it is still needed.
|
|
//
|
|
// Two places ask the same question — the Watchlist tab and the docked widget —
|
|
// and the answer involves a debounced round trip to the logbook per visible
|
|
// slot. Written twice it would be two definitions of "needed" drifting apart,
|
|
// and two bursts of the same query on every spot; written here it is one.
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { ClusterSpot } from '@/components/ClusterGrid';
|
|
import { inferSpotMode } from '@/lib/spot';
|
|
import { WatchlistWorkedSlots } from '../../wailsjs/go/main/App';
|
|
|
|
export interface WLEntry {
|
|
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
|
|
isContest: boolean; notify: boolean;
|
|
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
|
|
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
|
|
}
|
|
|
|
// A spot is ON AIR while its last sighting is this fresh.
|
|
export 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 list and the alerts can
|
|
// never disagree about what an entry covers.
|
|
export 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;
|
|
}
|
|
|
|
// What the cluster's verdict is worth saying, and in what colour.
|
|
//
|
|
// The order of severity is the one the band map and Chase new use: a new entity
|
|
// first, then the band and mode inside it. Shared because the tab and the docked
|
|
// widget must not label the same spot differently — an operator reads one of
|
|
// them to decide whether to leave what they are doing.
|
|
export const NEW_BADGES: Record<string, { key: string; colour: string }> = {
|
|
'new': { key: 'wl.newDxcc', colour: 'var(--danger)' },
|
|
'new-band-mode': { key: 'clg2.newBandMode', colour: 'var(--danger)' },
|
|
'new-band': { key: 'clg2.newBand', colour: 'var(--warning)' },
|
|
'new-mode': { key: 'clg2.newMode', colour: 'var(--caution)' },
|
|
'new-slot': { key: 'clg2.newSlot', colour: '#5AC8FA' },
|
|
};
|
|
|
|
export function newBadge(status?: string): { key: string; colour: string } | null {
|
|
return (status && NEW_BADGES[status]) || null;
|
|
}
|
|
|
|
export interface WatchlistSpots {
|
|
// The raw verdicts, exposed for dependency arrays: it changes only when an
|
|
// answer arrives, where the closures below are new on every render.
|
|
worked: Record<string, boolean>;
|
|
// The spots each entry covers, deduplicated by band+mode.
|
|
spotsFor: Map<string, ClusterSpot[]>;
|
|
// Worked on this exact slot (and, for a contest entry, today).
|
|
workedFor: (e: WLEntry, s: ClusterSpot) => boolean;
|
|
// Whether the logbook has actually answered for this pair yet. A spot whose
|
|
// verdict has not come back 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.
|
|
settled: (e: WLEntry, s: ClusterSpot) => boolean;
|
|
onAir: (e: WLEntry) => boolean;
|
|
}
|
|
|
|
export function useWatchlistSpots(entries: WLEntry[], spots: ClusterSpot[]): WatchlistSpots {
|
|
const [worked, setWorked] = useState<Record<string, boolean>>({});
|
|
|
|
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]);
|
|
|
|
// 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}`;
|
|
|
|
return {
|
|
worked,
|
|
spotsFor,
|
|
workedFor: (e, s) => worked[wkey(e, s)] ?? false,
|
|
settled: (e, s) => wkey(e, s) in worked,
|
|
onAir: (e) => (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;
|
|
}),
|
|
};
|
|
}
|