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:
+88
-12
@@ -120,6 +120,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
|
||||
import { WatchlistWidget } from '@/components/WatchlistWidget';
|
||||
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
|
||||
import { GridSquareMap } from '@/components/GridSquareMap';
|
||||
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
|
||||
@@ -2774,6 +2775,9 @@ export default function App() {
|
||||
|
||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||
// Off by default: it is an alerting panel, and one that appears uninvited on
|
||||
// an operator who does not keep a watch list is just a box saying "empty".
|
||||
const [showWatchWidget, setShowWatchWidget] = useState(() => localStorage.getItem('opslog.showWatchWidget') === '1');
|
||||
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
|
||||
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
|
||||
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
|
||||
@@ -6357,13 +6361,41 @@ export default function App() {
|
||||
// Follow the station the digital application says it is calling. A decode
|
||||
// clicked here sets the target directly (see onCall); this covers the QSO
|
||||
// started from the other side — WSJT-X's own double-click, or auto-call.
|
||||
//
|
||||
// On the EDGE — when the decoder's DX call actually changes — and not by
|
||||
// comparing it with the panel's current target: the auto-call effect below
|
||||
// sets that target too, and two effects each restoring "their" value from the
|
||||
// other's write is a loop, not a preference.
|
||||
const pskDxRef = useRef('');
|
||||
useEffect(() => {
|
||||
const dx = (txState?.dx_call ?? '').toUpperCase().trim();
|
||||
if (dx && dx !== pskTarget) {
|
||||
setPskTarget(dx);
|
||||
setPskTargetMode(txState?.mode ?? '');
|
||||
}
|
||||
}, [txState?.dx_call, txState?.mode, pskTarget]);
|
||||
if (!dx || dx === pskDxRef.current) return;
|
||||
pskDxRef.current = dx;
|
||||
setPskTarget(dx);
|
||||
setPskTargetMode(txState?.mode ?? '');
|
||||
}, [txState?.dx_call, txState?.mode]);
|
||||
|
||||
// WAITING FOR A STATION IS ALREADY A REASON TO ANALYSE IT.
|
||||
//
|
||||
// Auto-call shows an hourglass for a station it wants and cannot call yet
|
||||
// because that station is working somebody else. The analysis takes a moment
|
||||
// to fill — the history query, then a period or two of live reports — so
|
||||
// starting it at the instant the DX becomes free is starting it too late.
|
||||
// The wait is dead time, and this is exactly what it is worth spending on.
|
||||
//
|
||||
// Only while nothing is actually being called: a real target is the panel's
|
||||
// subject, and it arrives here through the decoder's DX call above.
|
||||
const pskWaitRef = useRef('');
|
||||
useEffect(() => {
|
||||
const w = String(autoCallStatus?.waiting ?? '').toUpperCase().trim();
|
||||
if (!w) { pskWaitRef.current = ''; return; }
|
||||
if (autoCallStatus?.target) return;
|
||||
if (w === pskWaitRef.current) return;
|
||||
pskWaitRef.current = w;
|
||||
setPskTarget(w);
|
||||
setPskTargetMode(txState?.mode ?? mode ?? '');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoCallStatus?.waiting, autoCallStatus?.target]);
|
||||
|
||||
const renderDecodesPanel = () => (
|
||||
<div className="flex h-full min-h-0">
|
||||
@@ -6818,6 +6850,18 @@ export default function App() {
|
||||
<Ear className="size-4" />
|
||||
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showWatchWidget; setShowWatchWidget(v); writeUiPref('opslog.showWatchWidget', v ? '1' : '0'); }}
|
||||
title={showWatchWidget ? t('wlw.hide') : t('wlw.show')}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
showWatchWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||
@@ -7300,12 +7344,30 @@ export default function App() {
|
||||
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
|
||||
</div>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{(clLang === 'fr' ? e.fr : e.en).map((line, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
|
||||
<span>{line}</span>
|
||||
</li>
|
||||
))}
|
||||
{(clLang === 'fr' ? e.fr : e.en).map((line, i) => {
|
||||
// "[NEW] " at the head of an entry marks a new FEATURE, as
|
||||
// opposed to a fix to one. The marker is written in the
|
||||
// changelog file itself and is the same in both languages,
|
||||
// so one convention covers EN and FR; it is stripped here
|
||||
// and drawn as a pill. A release is mostly fixes, and the
|
||||
// two or three things that are actually new should not have
|
||||
// to be found by reading all of it.
|
||||
const isNew = line.startsWith('[NEW] ');
|
||||
const text = isNew ? line.slice(6) : line;
|
||||
return (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className={cn('mt-1.5 size-1.5 rounded-full shrink-0', isNew ? 'bg-success' : 'bg-primary')} />
|
||||
<span>
|
||||
{isNew && (
|
||||
<span className="mr-1.5 align-[1px] rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide bg-success text-success-foreground">
|
||||
{t('whatsnew.newTag')}
|
||||
</span>
|
||||
)}
|
||||
{text}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
@@ -7527,7 +7589,7 @@ export default function App() {
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || showWatchWidget || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||
// the entry strip and each widget fills that height, scrolling inside.
|
||||
@@ -7592,6 +7654,20 @@ export default function App() {
|
||||
controls column, so the widget is just the dial and needs only its
|
||||
width. The classic dial sizes itself from the inside and expects a fixed
|
||||
column; the current one asks for the width it needs. */}
|
||||
{showWatchWidget && (
|
||||
<div className="w-[260px] shrink-0 min-h-0" style={{ order: wOrder('watchlist') }}>
|
||||
{/* Same handler as a cluster row, for the same reason as Chase
|
||||
new: a row here IS a spot, and half the reflex — the call
|
||||
without the frequency, or the frequency without the mode — is
|
||||
what makes a shortcut not worth using. */}
|
||||
<WatchlistWidget
|
||||
spots={spots}
|
||||
spotStatus={spotStatus as any}
|
||||
onPick={(s) => handleSpotClick(s as any)}
|
||||
onClose={() => { setShowWatchWidget(false); writeUiPref('opslog.showWatchWidget', '0'); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||
<div className={cn('shrink-0 min-h-0',
|
||||
rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ const en: Dict = {
|
||||
'wl.addPh': 'Callsign or VK9*', 'wl.added': '{call} added to the watchlist.', 'wl.addedContest': '{call} added as a contest entry (judged per UTC day).', 'wl.add': 'Add', 'wl.contest': 'Contest', 'wl.addAsContest': 'as contest', 'wl.cTotal': 'Watchlist:', 'wl.cActive': 'Active:', 'wl.cNeeded': 'Needed:', 'wl.allModes': 'All modes', 'wl.modeFilter': 'Only show spots in this mode', 'wl.patternPh': 'Auto contest (e.g. WWA)', 'wl.patternHint': 'Auto-add as contest: any spotted callsign CONTAINING this text joins the watchlist as a contest entry by itself. Empty = off; collected entries stay.',
|
||||
'wl.contestHint': 'Contest station: worked/needed is judged against the current UTC day — at 00:00 UTC every slot can be worked again.',
|
||||
'wl.searchPh': 'Search…', 'wl.famAll': 'All', 'wl.famNormal': 'DX', 'wl.famContest': 'Contest',
|
||||
'wl.activeOnly': 'Active', 'wl.neededOnly': 'Needed',
|
||||
'wlw.title': 'Watch list', 'wlw.show': 'Watch list — on the air and needed', 'wlw.hide': 'Hide the watch-list panel', 'wlw.emptyList': 'Nothing on the watch list yet.', 'wlw.emptyNone': 'Nothing on the air that you still need.', 'wlw.pick': 'Tune to {call}', 'wl.activeOnly': 'Active', 'wl.neededOnly': 'Needed',
|
||||
'wl.empty': 'Add the callsigns you are hunting. A bare entry matches exactly that call; end it with * for a family — VK9* catches every VK9…, RI0SP* the expedition\u2019s portable forms. Spots from the cluster appear under each entry with what they are worth.',
|
||||
'wl.noneMatch': 'Nothing matches the current filters.',
|
||||
'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpedition',
|
||||
@@ -76,7 +76,7 @@ const en: Dict = {
|
||||
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||
// Language chooser
|
||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
|
||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.newTag': 'New', 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
|
||||
'settings.language': 'Language', 'gen.dateFormat': 'Date display', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'French', 'gen.dateUS': 'US', 'settings.languageHint': 'Interface language.',
|
||||
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||
@@ -131,7 +131,7 @@ const en: Dict = {
|
||||
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
||||
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
|
||||
'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'wo.title': 'Widget order', 'wo.hint': 'The row to the right of the entry, in the order it appears. Drag to rearrange. A widget you have switched off keeps its place and comes back where you left it.', 'wo.drag': 'Drag to move', 'wo.reset': 'Reset to the default order', 'wo.entry': 'QSO entry', 'wo.details': 'Extra information (F1-F5)', 'wo.livestations': 'Who is on air', 'wo.chat': 'Chat', 'wo.rotor': 'Rotator compass', 'wo.motorant': 'Motorised antenna', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplifier', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'CW keyer', 'wo.photo': 'Operator photo', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)',
|
||||
'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'wo.title': 'Widget order', 'wo.hint': 'The row to the right of the entry, in the order it appears. Drag to rearrange. A widget you have switched off keeps its place and comes back where you left it.', 'wo.drag': 'Drag to move', 'wo.reset': 'Reset to the default order', 'wo.entry': 'QSO entry', 'wo.details': 'Extra information (F1-F5)', 'wo.livestations': 'Who is on air', 'wo.chat': 'Chat', 'wo.rotor': 'Rotator compass', 'wo.motorant': 'Motorised antenna', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplifier', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.watchlist': 'Watch list', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'CW keyer', 'wo.photo': 'Operator photo', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)',
|
||||
'appr.matrixEnable': 'Choose the band/mode matrix colours', 'appr.matrixHint': '(the PH/CW/DIG grid in Stats — off, each theme uses its own)',
|
||||
'appr.matrixSample': 'Sample', 'appr.matrixReset': 'Back to the theme’s colours',
|
||||
// Matrix legend + colour names. One set of labels for the grid's legend, its
|
||||
@@ -603,7 +603,7 @@ const fr: Dict = {
|
||||
'wl.addPh': 'Indicatif ou VK9*', 'wl.added': '{call} ajouté à la watchlist.', 'wl.addedContest': '{call} ajouté en entrée contest (jugée par jour UTC).', 'wl.add': 'Ajouter', 'wl.contest': 'Contest', 'wl.addAsContest': 'comme contest', 'wl.cTotal': 'Watchlist :', 'wl.cActive': 'Actives :', 'wl.cNeeded': 'Manquantes :', 'wl.allModes': 'Tous les modes', 'wl.modeFilter': 'Ne montrer que les spots de ce mode', 'wl.patternPh': 'Auto contest (ex. WWA)', 'wl.patternHint': "Ajout auto comme contest : tout indicatif spotté CONTENANT ce texte rejoint la watchlist en entrée contest tout seul. Vide = désactivé ; les entrées déjà collectées restent.",
|
||||
'wl.contestHint': "Station contest : contacté/manquant est jugé sur la journée UTC courante — à 00:00 UTC chaque créneau redevient à faire.",
|
||||
'wl.searchPh': 'Chercher…', 'wl.famAll': 'Tous', 'wl.famNormal': 'DX', 'wl.famContest': 'Contest',
|
||||
'wl.activeOnly': 'Actifs', 'wl.neededOnly': 'Manquants',
|
||||
'wlw.title': 'Watchlist', 'wlw.show': 'Watchlist — en l’air et à faire', 'wlw.hide': 'Masquer le panneau watchlist', 'wlw.emptyList': 'Aucune station dans la watchlist.', 'wlw.emptyNone': 'Rien en l’air qui vous manque encore.', 'wlw.pick': 'Se caler sur {call}', 'wl.activeOnly': 'Actifs', 'wl.neededOnly': 'Manquants',
|
||||
'wl.empty': "Ajoutez les indicatifs que vous chassez. Une entrée nue matche exactement ce call ; terminez par * pour une famille — VK9* attrape tous les VK9…, RI0SP* les formes portables de l'expédition. Les spots du cluster apparaissent sous chaque entrée avec ce qu'ils valent.",
|
||||
'wl.noneMatch': 'Rien ne correspond aux filtres actuels.',
|
||||
'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpédition',
|
||||
@@ -633,7 +633,7 @@ const fr: Dict = {
|
||||
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
|
||||
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
|
||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.newTag': 'Nouveau', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
|
||||
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
|
||||
'settings.language': 'Langue', 'gen.dateFormat': 'Affichage des dates', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'Fran\u00e7ais', 'gen.dateUS': 'US', 'settings.languageHint': "Langue de l'interface.",
|
||||
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||
@@ -688,7 +688,7 @@ const fr: Dict = {
|
||||
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
||||
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
||||
'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'wo.title': 'Ordre des widgets', 'wo.hint': 'La rangée à droite de la saisie, dans son ordre d’affichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous l’aviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir l’ordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à l’air', 'wo.chat': 'Chat', 'wo.rotor': 'Boussole rotor', 'wo.motorant': 'Antenne motorisée', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplificateur', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'Manipulateur CW', 'wo.photo': 'Photo de l’opérateur', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)',
|
||||
'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'wo.title': 'Ordre des widgets', 'wo.hint': 'La rangée à droite de la saisie, dans son ordre d’affichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous l’aviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir l’ordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à l’air', 'wo.chat': 'Chat', 'wo.rotor': 'Boussole rotor', 'wo.motorant': 'Antenne motorisée', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplificateur', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.watchlist': 'Watchlist', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'Manipulateur CW', 'wo.photo': 'Photo de l’opérateur', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)',
|
||||
'appr.matrixEnable': 'Choisir les couleurs de la matrice bandes/modes', 'appr.matrixHint': '(la grille PH/CW/DIG des Stats — décoché, chaque thème garde les siennes)',
|
||||
'appr.matrixSample': 'Aperçu', 'appr.matrixReset': 'Revenir aux couleurs du thème',
|
||||
// Légende de la matrice + noms des couleurs. Un seul jeu de libellés pour la
|
||||
|
||||
@@ -44,6 +44,10 @@ const PORTABLE_KEYS = [
|
||||
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
|
||||
'opslog.chasePota', // show POTA references and the NEW POTA marker on spots
|
||||
'opslog.chaseSota', // show SOTA references on spots
|
||||
// The other chase switches. In the DB as well as locally because AUTO-CALL
|
||||
// reads them: what the operator does not hunt is not something to transmit
|
||||
// for, and the backend cannot see localStorage.
|
||||
'opslog.chasePfx', 'opslog.chaseCounty', 'opslog.chaseState', 'opslog.chaseGrids',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
||||
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user