feat(watchlist): dockable in the Main tab; sticky filters; Needed-only hides worked lines

The watchlist joins the Main-tab pane list (Settings → Main view), rendered in
a card like the other docked panels.

The three filters persist across tab switches — the component unmounts on every
switch, and filters that reset each time are filters nobody trusts.

Needed-only now hides the worked SPOT LINES as well as the all-worked cards,
which is DXHunter's own Not-Worked behaviour and the reported complaint: a
filter that says needed and still lists five green Worked rows answers a
different question than the one asked.

The add-row checkbox is relabelled 'as contest' — it marks the entry being
ADDED, and read as a filter beside the All/DX/Contest buttons it looked
redundant.
This commit is contained in:
2026-08-29 00:39:07 +02:00
parent 29d79fb5d1
commit 7322e2ab66
4 changed files with 31 additions and 12 deletions
+9 -2
View File
@@ -2018,7 +2018,7 @@ export default function App() {
// so it's loaded async on mount and re-read on profile:changed below.
// 'none' is only ever stored for the third and fourth panes: the first two are
// the Main view, and a layout with no panes at all is not a layout.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'tci' | 'netcontrol' | 'decodes' | 'none';
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'tci' | 'netcontrol' | 'decodes' | 'watchlist' | 'none';
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
@@ -2029,7 +2029,7 @@ export default function App() {
// quarter-width map is unreadable.
const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad');
const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || v === 'netcontrol' || v === 'decodes';
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || v === 'netcontrol' || v === 'decodes' || v === 'watchlist';
const [l, r, p3, p4, lay] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''),
@@ -6339,6 +6339,13 @@ export default function App() {
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div>
);
case 'watchlist':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WatchlistTab spots={spots} spotStatus={spotStatus}
onSpotSelect={handleSpotSelect} onSpotClick={handleSpotClick} />
</div>
);
case 'netcontrol':
return (
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
+1 -1
View File
@@ -1086,7 +1086,7 @@ function RelayAutoPanel() {
// panes. The first two are the view; the third and fourth can be left empty, and
// an empty one is not drawn at all. Per-profile (stored via SetUIPref, which is
// profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes'];
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes', 'watchlist'];
const PANE_NONE = 'none';
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable, elecraftAvailable, tciAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean; elecraftAvailable?: boolean; tciAvailable?: boolean }) {
const { t } = useI18n();
+18 -6
View File
@@ -52,9 +52,17 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
const [addCall, setAddCall] = useState('');
const [addContest, setAddContest] = useState(false);
const [search, setSearch] = useState('');
const [neededOnly, setNeededOnly] = useState(false);
const [activeOnly, setActiveOnly] = useState(false);
const [family, setFamily] = useState<'all' | 'normal' | 'contest'>('all');
// The filters survive leaving the tab: the component unmounts on every tab
// switch, and filters that reset each time are filters nobody trusts.
const [neededOnly, setNeededOnlyRaw] = useState(() => localStorage.getItem('opslog.wlNeededOnly') === '1');
const [activeOnly, setActiveOnlyRaw] = useState(() => localStorage.getItem('opslog.wlActiveOnly') === '1');
const [family, setFamilyRaw] = useState<'all' | 'normal' | 'contest'>(() => {
const v = localStorage.getItem('opslog.wlFamily');
return v === 'normal' || v === 'contest' ? v : 'all';
});
const setNeededOnly = (f: (v: boolean) => boolean) => setNeededOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlNeededOnly', nv ? '1' : '0'); } catch {} return nv; });
const setActiveOnly = (f: (v: boolean) => boolean) => setActiveOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlActiveOnly', nv ? '1' : '0'); } catch {} return nv; });
const setFamily = (v: 'all' | 'normal' | 'contest') => { setFamilyRaw(v); try { localStorage.setItem('opslog.wlFamily', v); } catch {} };
const [error, setError] = useState('');
const [removeArm, setRemoveArm] = useState('');
// worked answer per "call|band|modeclass|contest" key.
@@ -178,7 +186,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
onKeyDown={(e) => { if (e.key === 'Enter') void add(); }} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('wl.contestHint')}>
<Checkbox checked={addContest} onCheckedChange={(c) => setAddContest(!!c)} />
<Trophy className="size-3.5 text-warning" /> {t('wl.contest')}
<Trophy className="size-3.5 text-warning" /> {t('wl.addAsContest')}
</label>
<Button size="sm" className="h-8" onClick={() => void add()} disabled={!addCall.trim()}>
<Plus className="size-3.5" /> {t('wl.add')}
@@ -218,8 +226,12 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
{entries.length === 0 ? t('wl.empty') : t('wl.noneMatch')}
</div>
) : shown.map((e) => {
const list = spotsFor.get(e.callsign) ?? [];
const needed = list.filter((s) => !workedFor(e, s)).length;
const all = spotsFor.get(e.callsign) ?? [];
const needed = all.filter((s) => !workedFor(e, s)).length;
// Needed-only hides the worked LINES as well as the all-worked cards:
// a filter that says needed and still lists five green Worked rows is
// answering a different question than the one asked.
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
return (
<div key={e.callsign}
className={cn('rounded-lg border bg-card p-3',
+3 -3
View File
@@ -39,7 +39,7 @@ const en: Dict = {
'cwd.tipOnIdle': 'CW decoder — on, idle until CW mode · click to disable',
'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)',
'tools.watchlist': 'Watchlist…', 'tab.watchlist': 'Watchlist',
'wl.addPh': 'Callsign or prefix', 'wl.add': 'Add', 'wl.contest': 'Contest',
'wl.addPh': 'Callsign or prefix', 'wl.add': 'Add', 'wl.contest': 'Contest', 'wl.addAsContest': 'as contest',
'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 only', 'wl.neededOnly': 'Needed only',
@@ -122,7 +122,7 @@ const en: Dict = {
'settings.leftPane': 'Left pane', 'settings.thirdPane': 'Third pane', 'settings.fourthPane': 'Fourth pane', 'settings.paneNone': '— none —', 'settings.paneLayout': 'With four panes:', 'settings.paneQuad': 'Quarters (2 × 2)', 'settings.paneCols': 'Four columns', 'settings.rightPane': 'Right pane',
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', 'settings.pane.decodes': 'FT decodes',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', 'settings.pane.decodes': 'FT decodes', 'settings.pane.watchlist': 'Watchlist',
'settings.pane.flex': 'Flex Console', 'tcip.console': "SunSDR Console", 'settings.pane.tci': "SunSDR Console", 'settings.pane.elecraft': "Elecraft Console", 'tcip.waiting': "Waiting for the radio — TCI is not connected.", 'tcip.meters': "Meters", 'tcip.transmit': "Transmit", 'tcip.receive': "Receive", 'tcip.drive': "Drive", 'tcip.tuneDrive': "Tune drive", 'tcip.mic': "Mic gain", 'tcip.volume': "Volume", 'tcip.squelch': "Squelch", 'tcip.agc': "AGC", 'tcip.filter': "Filter", 'tcip.mute': "MUTE", 'tcip.lock': "LOCK", 'tcip.off': "off", 'tcip.tune': "TUNE", 'tcip.tuning': "TUNING", 'tcip.txDisabled': "The radio is not allowing transmit", 'tcip.sMeterHint': "Click to put this report in the entry form. The radio sends a real signal level in dBm, so the S units are arithmetic rather than a calibration guess.", 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.narrowHint': 'Narrow IF filter (NAR) — tightens the receive bandwidth. Only shown when the radio answers the command.', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode; click again to switch sideband (U/L)', 'yaesu.splitUpHint': 'Transmit this far above the receive frequency, and turn split on', 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': 'Break-in: the rig switches to receive between characters', 'yaesu.zinHint': 'Zero-in: retune so the station you hear lands on your CW pitch',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
'theme.light-sage': 'Sage light', 'theme.light-nordic': 'Nordic light', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
@@ -563,7 +563,7 @@ const fr: Dict = {
'cwd.tipOnIdle': 'Décodeur CW — actif, en veille hors mode CW · clic pour désactiver',
'cwd.tipOff': 'Décodeur CW · clic pour activer (décode laudio RX en mode CW)',
'tools.watchlist': 'Watchlist…', 'tab.watchlist': 'Watchlist',
'wl.addPh': 'Indicatif ou préfixe', 'wl.add': 'Ajouter', 'wl.contest': 'Contest',
'wl.addPh': 'Indicatif ou préfixe', 'wl.add': 'Ajouter', 'wl.contest': 'Contest', 'wl.addAsContest': 'comme contest',
'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 seulement', 'wl.neededOnly': 'Manquants seulement',