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