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
+88 -12
View File
@@ -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')}
+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>
);
}
+6 -6
View File
@@ -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 themes 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 lair et à faire', 'wlw.hide': 'Masquer le panneau watchlist', 'wlw.emptyList': 'Aucune station dans la watchlist.', 'wlw.emptyNone': 'Rien en lair 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 daffichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous laviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir lordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à lair', '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 lopé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 daffichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous laviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir lordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à lair', '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 lopé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
+4
View File
@@ -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:[..]}
+133
View File
@@ -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;
}),
};
}