// 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 = { '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; // The spots each entry covers, deduplicated by band+mode. spotsFor: Map; // 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>({}); const spotsFor = useMemo(() => { const map = new Map(); 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(); 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(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; }), }; }