diff --git a/app.go b/app.go
index b7d6277..307c2dc 100644
--- a/app.go
+++ b/app.go
@@ -650,6 +650,12 @@ type App struct {
// bounds itself by age instead, so memory follows how many distinct stations
// have actually been heard in two years rather than a made-up ceiling.
gridStore *gridcache.Store
+
+ // chaseNew holds the "new against the log" stations PSK Reporter is hearing
+ // near here; chaseNewOn is the option cached for the MQTT goroutine, which
+ // consults it once per message and must not reach the settings store.
+ chaseNew *chaseNewStore
+ chaseNewOn atomic.Bool
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
// It is the source that makes VHF detection work at all: the cluster and RBN
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
@@ -1415,8 +1421,11 @@ func (a *App) startup(ctx context.Context) {
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
go a.hrdlogOnAirLoop() // publish frequency/mode/rig on hrdlog.net when enabled
// Locator store BEFORE the feed: the feed asks whether it exists to decide
- // which bands to subscribe to.
+ // which bands to subscribe to. Same for the chase-new option, read into its
+ // atomic here so the feed and the MQTT goroutine agree from the first message.
a.startGridCache()
+ a.chaseNew = newChaseNewStore()
+ a.refreshChaseNew()
// PSK Reporter. After the operator's grid is known: without it there is no
// distance to measure and no receiver squares to filter on, so it stays down.
a.startBandOpenFeed()
diff --git a/bandopen_sources.go b/bandopen_sources.go
index f999d7a..2dedc2a 100644
--- a/bandopen_sources.go
+++ b/bandopen_sources.go
@@ -138,7 +138,10 @@ func (a *App) startBandOpenFeed() {
a.clearBandOpenings()
}
chaseGrids := a.gridStore != nil
- if !s.Enabled && !chaseGrids {
+ chaseNew := a.chaseNewEnabled()
+ // Three consumers, one feed. Any one of them is reason enough to bring it up,
+ // and turning one off must not cut the others loose.
+ if !s.Enabled && !chaseGrids && !chaseNew {
return
}
// Every spot is measured from the operator's position. Without one there is
@@ -149,10 +152,11 @@ func (a *App) startBandOpenFeed() {
return
}
- // Grid chasing wants every band; the opening watch wants its four. "+" is the
- // MQTT single-level wildcard, so one subscription per square covers the lot.
+ // Grid chasing and new-chasing want every band; the opening watch wants its
+ // four. "+" is the MQTT single-level wildcard, so one subscription per square
+ // covers the lot.
bands := s.Bands
- if chaseGrids {
+ if chaseGrids || chaseNew {
bands = []string{"+"}
}
// Filter at the BROKER on the receiver's square rather than receiving the
@@ -168,8 +172,13 @@ func (a *App) startBandOpenFeed() {
onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
}
var onSpot func(pskr.Spot)
- if s.Enabled {
+ switch {
+ case s.Enabled && chaseNew:
+ onSpot = func(sp pskr.Spot) { a.feedBandOpen(sp); a.feedChaseNew(sp) }
+ case s.Enabled:
onSpot = a.feedBandOpen
+ case chaseNew:
+ onSpot = a.feedChaseNew
}
a.pskr = pskr.New(pskr.Config{
@@ -195,8 +204,8 @@ func (a *App) startBandOpenFeed() {
applog.Printf("pskr: feed did not start: %v", err)
return
}
- applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v)",
- bands, len(rxGrids), s.Enabled, chaseGrids)
+ applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v, chase-new=%v)",
+ bands, len(rxGrids), s.Enabled, chaseGrids, chaseNew)
}
// feedBandOpen hands one PSK Reporter decode to the detector.
diff --git a/changelog.json b/changelog.json
index 9a13dad..7f80a16 100644
--- a/changelog.json
+++ b/changelog.json
@@ -4,11 +4,13 @@
"date": "",
"en": [
"Send Spot: the comment now carries the award references after the mode — the ones you assigned (POTA, SOTA, IOTA…), not the DXCC, zone and prefix every reader works out from the callsign. A self-spot carries your OWN activation references instead.",
- "Modes: a fresh install now starts with SSB, CW, FT8, FT4, FT2, RTTY, PSK31 and FM. AM and DIGITALVOICE stay in the available list but are no longer selected by default."
+ "Modes: a fresh install now starts with SSB, CW, FT8, FT4, FT2, RTTY, PSK31 and FM. AM and DIGITALVOICE stay in the available list but are no longer selected by default.",
+ "Chase new: a panel listing the stations PSK Reporter is hearing within about 300 km of you that are new against your log — entity, band, mode, slot, prefix or square. Click one to put it in the entry and tune the rig. Digital modes only, and it shares the feed the band-opening watch and the locator store already use."
],
"fr": [
"Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de l’indicatif. Un auto-spot porte VOS références d’activation.",
- "Modes : une installation neuve démarre avec SSB, CW, FT8, FT4, FT2, RTTY, PSK31 et FM. AM et DIGITALVOICE restent dans la liste disponible mais ne sont plus sélectionnés par défaut."
+ "Modes : une installation neuve démarre avec SSB, CW, FT8, FT4, FT2, RTTY, PSK31 et FM. AM et DIGITALVOICE restent dans la liste disponible mais ne sont plus sélectionnés par défaut.",
+ "Chasse au nouveau : un panneau listant les stations que PSK Reporter entend à moins de 300 km de chez vous et qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Un clic la met en saisie et accorde la radio. Modes numériques uniquement, et le flux est partagé avec la veille d’ouvertures et la base des locators."
]
},
{
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3676759..01b0c33 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -51,7 +51,7 @@ import {
ReportLiveActivity, LiveLastQSOAgeSec,
GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate,
- GetPSKReporterStatus, GetLiveOpenings,
+ GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
@@ -82,6 +82,7 @@ import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
import { AmpWidget } from '@/components/AmpWidget';
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
+import { ChaseNewPanel } from '@/components/ChaseNewPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { StatsPanel } from '@/components/StatsPanel';
@@ -2086,6 +2087,12 @@ export default function App() {
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');
+ // The Chase New widget follows its own setting rather than a local toggle: the
+ // panel is only meaningful while the PSK Reporter feed is up, and that is what
+ // the setting decides.
+ const [chaseNewOn, setChaseNewOn] = useState(false);
+ const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
+ useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
@@ -6217,6 +6224,16 @@ export default function App() {
/>
)}
+ {chaseNewOn && (
+
+ {/* Same reflex as clicking a cluster spot: the callsign into the
+ entry, and the rig onto the frequency it was decoded on. */}
+ {
+ onCallsignInput(sp.call, { force: true });
+ if (sp.freq_hz) void tuneRigCAT(sp.freq_hz, sp.mode);
+ }} />
+
+ )}
{dvkEnabled && (
{ setShowSettings(false); setSettingsSection(undefined); }}
+ onClose={() => { setShowSettings(false); setSettingsSection(undefined); refreshChaseNew(); }}
onSaved={() => {
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
// Drop the cached spot statuses. They are computed once per
diff --git a/frontend/src/components/ChaseNewPanel.tsx b/frontend/src/components/ChaseNewPanel.tsx
new file mode 100644
index 0000000..1b946b1
--- /dev/null
+++ b/frontend/src/components/ChaseNewPanel.tsx
@@ -0,0 +1,126 @@
+// ChaseNewPanel — the stations PSK Reporter is hearing NEAR HERE that are new
+// against the log.
+//
+// A DX cluster tells you what somebody chose to spot. This tells you what is
+// actually being decoded in your own region, which is a different and often
+// larger set: nobody spots the FT8 caller running 10 watts from a rare square.
+//
+// The one thing an operator has to know, and the reason for the line at the
+// bottom: PSK Reporter carries DIGITAL MODES ONLY. An empty panel means nothing
+// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
+import { useEffect, useState } from 'react';
+import { Radar, Loader2 } from 'lucide-react';
+import { useI18n } from '@/lib/i18n';
+import { markerColour } from '@/lib/spotMarkers';
+import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
+
+export interface ChaseNewSpot {
+ call: string;
+ band: string;
+ mode: string;
+ freq_hz?: number;
+ grid?: string;
+ country?: string;
+ cont?: string;
+ dist_km?: number;
+ bearing?: number;
+ status?: string;
+ new_pfx?: boolean;
+ new_grid?: boolean;
+ lotw?: boolean;
+ at: string;
+}
+
+interface Props {
+ // Tuning the rig to a row is the whole point — a station heard on 14.074 is
+ // only useful if you can get there in one click.
+ onPick?: (s: ChaseNewSpot) => void;
+}
+
+// statusLabel maps the backend's vocabulary — the cluster's own — to a short
+// badge. Kept to the same words the DX cluster list uses: an operator should not
+// have to learn two names for one idea.
+function statusKey(s: ChaseNewSpot): string | null {
+ switch (s.status) {
+ case 'new': return 'clg2.newDxcc';
+ case 'new-band': return 'clg2.newBand';
+ case 'new-mode': return 'clg2.newMode';
+ case 'new-slot': return 'clg2.newSlot';
+ default: return null;
+ }
+}
+
+export function ChaseNewPanel({ onPick }: Props) {
+ const { t } = useI18n();
+ const [spots, setSpots] = useState([]);
+ const [loaded, setLoaded] = useState(false);
+
+ // Polled rather than pushed: the feed can deliver several a second under an
+ // opening, and an event per row would be a redraw per row for a list nobody
+ // reads that fast.
+ useEffect(() => {
+ let alive = true;
+ const tick = async () => {
+ try {
+ const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
+ if (alive) { setSpots(r); setLoaded(true); }
+ } catch { /* the feed may not be up yet */ }
+ };
+ tick();
+ const id = window.setInterval(tick, 5000);
+ return () => { alive = false; window.clearInterval(id); };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index d7f561b..a9a8aef 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -52,7 +52,7 @@ import {
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
- GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
+ GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -1562,6 +1562,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// feed up or down — so the write has to go where those live.
const [bandOpen, setBandOpen] = useState({ enabled: false, bands: [], available: [] });
const [chaseGrids, setChaseGrids] = useState(false);
+ const [chaseNew, setChaseNew] = useState(false);
const [spotTTL, setSpotTTL] = useState(0);
const [spotTTLText, setSpotTTLText] = useState('0');
const [gridStat, setGridStat] = useState(null);
@@ -1574,6 +1575,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
(async () => {
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
+ try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
})();
@@ -4339,6 +4341,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
+ {/* Chase new — its own option, NOT nested under grid chasing. Chasing
+ squares and chasing entities are different wants; they only share
+ the PSK Reporter feed, which either one brings up. */}
+
+
+
+
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
switching it on adds two of them — the operator should see that
happen where it happens rather than find nodes they did not add. */}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index da70d90..c920b76 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -429,6 +429,7 @@ const en: Dict = {
// Grids column headers (recent / worked-before / cluster)
'rqg.c.number': 'QSO number', 'rqg.h.number': 'N°', 'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date off', 'rqg.c.callsign': 'Callsign', 'rqg.c.band': 'Band', 'rqg.c.band_rx': 'Band RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Submode', 'rqg.c.freq_hz': 'Freq (TX)', 'rqg.h.freq_hz': 'Freq', 'rqg.c.freq_rx_hz': 'Freq (RX)', 'rqg.h.freq_rx_hz': 'Freq RX', 'rqg.c.rst_sent': 'RST sent', 'rqg.c.rst_rcvd': 'RST rcvd', 'rqg.c.tx_pwr': 'TX Power', 'rqg.c.name': 'Name', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Address', 'rqg.c.country': 'Country', 'rqg.c.state': 'State', 'rqg.c.cnty': 'County', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Grid', 'rqg.c.gridsquare_ext': 'Grid Ext', 'rqg.h.gridsquare_ext': 'GridExt', 'rqg.c.vucc_grids': 'VUCC grids', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'SOTA ref', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'POTA ref', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Age', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.distance_km': 'Distance (km)', 'rqg.h.distance_km': 'Dist km', 'rqg.c.email': 'Email', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL sent', 'rqg.c.qsl_rcvd': 'QSL rcvd', 'rqg.c.qsl_sent_date': 'QSL sent date', 'rqg.h.qsl_sent_date': 'QSL S date', 'rqg.c.qsl_rcvd_date': 'QSL rcvd date', 'rqg.h.qsl_rcvd_date': 'QSL R date', 'rqg.c.qsl_via': 'QSL via (manager)', 'rqg.c.qsl_sent_via': 'QSL sent via', 'rqg.h.qsl_sent_via': 'Sent via', 'rqg.c.qsl_rcvd_via': 'QSL rcvd via', 'rqg.h.qsl_rcvd_via': 'Rcvd via', 'rqg.c.qsl_msg': 'QSL msg', 'rqg.c.qslmsg_rcvd': 'QSL msg rcvd', 'rqg.c.lotw_sent': 'LoTW sent', 'rqg.c.lotw_rcvd': 'LoTW rcvd', 'rqg.c.lotw_sent_date': 'LoTW sent date', 'rqg.h.lotw_sent_date': 'LoTW S date', 'rqg.c.lotw_rcvd_date': 'LoTW rcvd date', 'rqg.h.lotw_rcvd_date': 'LoTW R date', 'rqg.c.eqsl_sent': 'eQSL sent', 'rqg.c.eqsl_rcvd': 'eQSL rcvd', 'rqg.c.eqsl_sent_date': 'eQSL sent date', 'rqg.h.eqsl_sent_date': 'eQSL S date', 'rqg.c.eqsl_rcvd_date': 'eQSL rcvd date', 'rqg.h.eqsl_rcvd_date': 'eQSL R date', 'rqg.c.opslog_qsl_card_sent': 'OpsLog QSL', 'rqg.c.opslog_qsl_card_rcvd': 'OpsLog QSL rcvd', 'rqg.c.opslog_recording_sent': 'Recording sent', 'rqg.h.opslog_recording_sent': 'Rec sent', 'rqg.c.clublog_sent': 'ClubLog sent', 'rqg.c.clublog_sent_date': 'ClubLog sent date', 'rqg.h.clublog_sent_date': 'ClubLog S date', 'rqg.c.hrdlog_sent': 'HRDLog sent', 'rqg.c.hrdlog_sent_date': 'HRDLog sent date', 'rqg.h.hrdlog_sent_date': 'HRDLog S date', 'rqg.c.qrz_sent': 'QRZ.com sent', 'rqg.c.qrz_rcvd': 'QRZ.com rcvd', 'rqg.c.qrz_sent_date': 'QRZ.com sent date', 'rqg.h.qrz_sent_date': 'QRZ.com S date', 'rqg.c.qrz_rcvd_date': 'QRZ.com rcvd date', 'rqg.h.qrz_rcvd_date': 'QRZ.com R date', 'rqg.c.contest_id': 'Contest ID', 'rqg.h.contest_id': 'Contest', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'SRX string', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'STX string', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Precedence', 'rqg.c.arrl_sect': 'ARRL section', 'rqg.h.arrl_sect': 'ARRL sect', 'rqg.c.prop_mode': 'Prop mode', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Sat name', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Sat mode', 'rqg.c.ant_az': 'Ant az', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Ant el', 'rqg.h.ant_el': 'El', 'rqg.c.ant_path': 'Ant path', 'rqg.h.ant_path': 'Path', 'rqg.c.station_callsign': 'Station call', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Operator', 'rqg.c.my_grid': 'My grid', 'rqg.c.my_country': 'My country', 'rqg.h.my_country': 'My ctry', 'rqg.c.my_state': 'My state', 'rqg.c.my_cnty': 'My county', 'rqg.h.my_cnty': 'My cnty', 'rqg.c.my_iota': 'My IOTA', 'rqg.c.my_sota': 'My SOTA', 'rqg.c.my_pota': 'My POTA', 'rqg.c.my_dxcc': 'My DXCC', 'rqg.h.my_dxcc': 'My DXCC#', 'rqg.c.my_cq_zone': 'My CQ zone', 'rqg.h.my_cq_zone': 'My CQZ', 'rqg.c.my_itu_zone': 'My ITU zone', 'rqg.h.my_itu_zone': 'My ITU', 'rqg.c.my_lat': 'My lat', 'rqg.c.my_lon': 'My lon', 'rqg.c.my_street': 'My street', 'rqg.h.my_street': 'Street', 'rqg.c.my_city': 'My city', 'rqg.h.my_city': 'City', 'rqg.c.my_zip': 'My ZIP', 'rqg.h.my_zip': 'ZIP', 'rqg.c.my_rig': 'My rig', 'rqg.c.my_antenna': 'My antenna', 'rqg.c.my_name': 'My name', 'rqg.c.my_wwff': 'My WWFF', 'rqg.c.my_sig': 'My SIG', 'rqg.c.my_sig_info': 'My SIG info', 'rqg.c.my_arrl_sect': 'My ARRL sect', 'rqg.c.my_darc_dok': 'My DARC DOK', 'rqg.c.my_vucc_grids': 'My VUCC grids', 'rqg.h.my_antenna': 'My ant', 'rqg.c.comment': 'Comment', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Created', 'rqg.h.created': 'Created at', 'rqg.c.updated': 'Updated', 'rqg.h.updated': 'Updated at', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Contacted', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Uploads', 'rqg.grpContest': 'Contest', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'My station', 'rqg.grpMisc': 'Misc', 'rqg.grpAwards': 'Awards', 'rqg.awardTip': '{name} — reference this QSO counts for', 'rqg.clearFiltersTitle': 'Clear all column filters', 'rqg.clearFilters': 'Clear filters', 'rqg.selectedCount': '{n} selected', 'rqg.selectAll': 'Select all', 'rqg.selectAllTitle': 'Select every displayed row (respects active column filters)', 'rqg.unselectAll': 'Unselect all', 'rqg.unselectAllTitle': 'Clear the selection', 'rqg.columns': 'Columns', 'rqg.pickerDesc': 'Pick the columns you want visible in the Recent QSOs table. Your selection is saved.', 'rqg.all': 'all', 'rqg.none': 'none', 'rqg.resetDefaults': 'Reset to defaults', 'rqg.done': 'Done',
'wbg.awardTip': '{name} — reference this QSO counts for', 'wbg.typeCall': 'Type a callsign in the entry strip to see prior contacts.', 'wbg.checking': 'checking…', 'wbg.new': 'NEW', 'wbg.noPriorPre': 'No prior QSO with ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Worked before', 'wbg.first': 'First:', 'wbg.last': 'Last:', 'wbg.dxcc': 'DXCC:', 'wbg.entityQsos': '{n} entity QSOs', 'wbg.clearFiltersTitle': 'Clear all column filters', 'wbg.clearFilters': 'Clear filters', 'wbg.columns': 'Columns', 'wbg.olderQsos': '+ {n} older QSOs (not shown — capped for performance)', 'wbg.pickerTitle': 'Worked-before columns', 'wbg.pickerDesc': 'Pick the columns you want visible in the Worked-before table.', 'wbg.all': 'all', 'wbg.none': 'none', 'wbg.grpAwards': 'Awards', 'wbg.resetDefaults': 'Reset to defaults', 'wbg.done': 'Done',
+ 'chn.title': 'Chase new', 'chn.count': '{n} heard', 'chn.loading': 'Waiting for the feed…', 'chn.empty': 'Nothing new being decoded near you right now.', 'chn.digitalOnly': 'PSK Reporter — digital modes only, heard within ~300 km of you.', 'chn.option': 'Chase new (PSK Reporter)', 'chn.optionHelp': 'Lists stations being decoded near you that are new against your log — new entity, band, mode, slot, prefix or square. Digital modes only.', 'chn.show': 'Chase new panel',
'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Spotter locator', 'clg2.h.locator': 'Spotter loc', 'clg2.c.county': 'US County', 'clg2.tipNewCounty': 'NEW COUNTY — never worked', 'clg2.tipNewPfx': 'NEW PREFIX — this WPX prefix has never been worked', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.newCall': 'NEW CALL', 'clg2.wkdCall': 'WKD CALL', 'clg2.newCounty': 'NEW CTY', 'clg2.newGrid': 'NEW GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NEW GRID — this square has never been worked (grid heard in a CQ on the UDP link)', 'clg2.newPfx': "NEW PFX", 'clg2.newPota': 'NEW POTA', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipNewCall': 'NEW CALL — this callsign has never been worked on this band and mode (the entity has)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
// Audio devices & voice keyer (Preferences → Audio devices).
'aud.refreshDevices': 'Refresh devices', 'aud.fromRadio': 'From Radio (RX in)', 'aud.toRadio': 'To Radio (TX out)', 'aud.recMic': 'Recording mic', 'aud.listening': 'Listening (preview)',
@@ -837,6 +838,7 @@ const fr: Dict = {
'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confOpsLog': 'Carte OpsLog', 'qedit.opslogSentHint': "« Envoyée » est inscrit par OpsLog quand la carte part réellement — cela ne se modifie pas ici.", 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslReceived': 'QSL OpsLog reçue', 'qedit.pseTnxHint': 'Tampon carte : TNX QSL si une QSL a été reçue, sinon PSE QSL', 'qedit.qslVia': 'QSL via (manager)', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.sentVia': 'Envoyée via', 'qedit.rcvdVia': 'Reçue via', 'qedit.viaBureau': 'Bureau', 'qedit.viaDirect': 'Direct', 'qedit.viaElectronic': 'Électronique (OQRS)', 'qedit.qslViaPlaceholder': 'indicatif du manager', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…',
'rqg.c.number': 'Numéro de QSO', 'rqg.h.number': 'N°', 'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.distance_km': 'Distance (km)', 'rqg.h.distance_km': 'Dist km', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via (manager)', 'rqg.c.qsl_sent_via': 'QSL envoyée via', 'rqg.h.qsl_sent_via': 'Env. via', 'rqg.c.qsl_rcvd_via': 'QSL reçue via', 'rqg.h.qsl_rcvd_via': 'Reçue via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_qsl_card_rcvd': 'QSL OpsLog reçue', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.c.my_name': 'Mon nom', 'rqg.c.my_wwff': 'Mon WWFF', 'rqg.c.my_sig': 'Mon SIG', 'rqg.c.my_sig_info': 'Mon info SIG', 'rqg.c.my_arrl_sect': 'Ma section ARRL', 'rqg.c.my_darc_dok': 'Mon DARC DOK', 'rqg.c.my_vucc_grids': 'Mes carrés VUCC', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.selectedCount': '{n} sélectionné(s)', 'rqg.selectAll': 'Tout sélectionner', 'rqg.selectAllTitle': 'Sélectionner toutes les lignes affichées (respecte les filtres de colonnes actifs)', 'rqg.unselectAll': 'Tout désélectionner', 'rqg.unselectAllTitle': 'Effacer la sélection', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé',
'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé',
+ 'chn.title': 'Chasse au nouveau', 'chn.count': '{n} entendus', 'chn.loading': 'En attente du flux…', 'chn.empty': 'Rien de nouveau décodé près de vous pour le moment.', 'chn.digitalOnly': 'PSK Reporter — modes numériques uniquement, entendus à moins de ~300 km.', 'chn.option': 'Chasse au nouveau (PSK Reporter)', 'chn.optionHelp': 'Liste les stations décodées près de chez vous qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Modes numériques uniquement.', 'chn.show': 'Panneau chasse au nouveau',
'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ CTC', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
// Périphériques audio et manipulateur vocal (Préférences → Périphériques audio).
'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)',
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index 5f3f3db..6db8e0f 100644
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -406,8 +406,12 @@ export function GetCatalogCodes():Promise>;
export function GetChangelog():Promise>;
+export function GetChaseNew():Promise;
+
export function GetChaseNewGrids():Promise;
+export function GetChaseNewSpots():Promise>;
+
export function GetChatHistory(arg1:number):Promise>;
export function GetClublogCtyInfo():Promise;
@@ -992,6 +996,8 @@ export function SetCIVTrace(arg1:boolean):Promise;
export function SetCWDecoderPitch(arg1:number):Promise;
+export function SetChaseNew(arg1:boolean):Promise;
+
export function SetChaseNewGrids(arg1:boolean):Promise;
export function SetClublogCtyEnabled(arg1:boolean):Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index f6638e8..801252c 100644
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -754,10 +754,18 @@ export function GetChangelog() {
return window['go']['main']['App']['GetChangelog']();
}
+export function GetChaseNew() {
+ return window['go']['main']['App']['GetChaseNew']();
+}
+
export function GetChaseNewGrids() {
return window['go']['main']['App']['GetChaseNewGrids']();
}
+export function GetChaseNewSpots() {
+ return window['go']['main']['App']['GetChaseNewSpots']();
+}
+
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
@@ -1926,6 +1934,10 @@ export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
+export function SetChaseNew(arg1) {
+ return window['go']['main']['App']['SetChaseNew'](arg1);
+}
+
export function SetChaseNewGrids(arg1) {
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 42e0ff1..7fd524a 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -2131,6 +2131,44 @@ export namespace main {
this.fr = source["fr"];
}
}
+ export class ChaseNewSpot {
+ call: string;
+ band: string;
+ mode: string;
+ freq_hz: number;
+ grid: string;
+ country?: string;
+ cont?: string;
+ dist_km: number;
+ bearing: number;
+ status?: string;
+ new_pfx?: boolean;
+ new_grid?: boolean;
+ lotw?: boolean;
+ at: string;
+
+ static createFrom(source: any = {}) {
+ return new ChaseNewSpot(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.call = source["call"];
+ this.band = source["band"];
+ this.mode = source["mode"];
+ this.freq_hz = source["freq_hz"];
+ this.grid = source["grid"];
+ this.country = source["country"];
+ this.cont = source["cont"];
+ this.dist_km = source["dist_km"];
+ this.bearing = source["bearing"];
+ this.status = source["status"];
+ this.new_pfx = source["new_pfx"];
+ this.new_grid = source["new_grid"];
+ this.lotw = source["lotw"];
+ this.at = source["at"];
+ }
+ }
export class ChatMessage {
id: number;
operator: string;
diff --git a/internal/pskr/pskr.go b/internal/pskr/pskr.go
index e7b0f69..a3b41e7 100644
--- a/internal/pskr/pskr.go
+++ b/internal/pskr/pskr.go
@@ -50,12 +50,15 @@ var Bands = []string{"10m", "6m", "4m", "2m"}
// Spot is one decode, already reduced to what a detector needs.
type Spot struct {
- Call string // the transmitting station
- Band string
- Mode string
- Grid string // transmitter's grid, 4 characters
- DistKm int // from the operator
- Bearing int // degrees from the operator, short path
+ Call string // the transmitting station
+ Band string
+ Mode string
+ Grid string // transmitter's grid, 4 characters
+ // FreqHz is where the decode happened. The band alone is enough for an
+ // opening, but a station worth chasing has to be tuned to.
+ FreqHz int64
+ DistKm int // from the operator
+ Bearing int // degrees from the operator, short path
At time.Time
}
diff --git a/pskchase.go b/pskchase.go
new file mode 100644
index 0000000..f957cf5
--- /dev/null
+++ b/pskchase.go
@@ -0,0 +1,220 @@
+package main
+
+// Chase New — a widget listing the stations PSK Reporter is hearing NEAR HERE
+// that are new against the log.
+//
+// The feed is the one the band-opening watch and the grid store already use, so
+// this costs no extra subscription when either is on: it reads messages that
+// were arriving and being discarded. Measured on the live broker, one ring of
+// neighbour squares is 0.2 to 1.2 messages a second.
+//
+// Two things about the data decide the shape of everything below:
+//
+// - PSK Reporter is DIGITAL ONLY. This can never show a new entity on CW or
+// SSB, and the panel says so rather than letting an operator conclude the
+// band is dead when it is full of CW.
+// - A report says "X was heard BY Y". The watcher already drops anything
+// collected further than NearKm from the operator (internal/pskr), so what
+// arrives here is a station being heard in this region — not a world map.
+//
+// "New" is NOT decided here. It goes through ClusterSpotStatuses, the same
+// function the DX cluster grid uses, because two definitions of new is how the
+// two panels quietly start disagreeing about the same callsign.
+
+import (
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "hamlog/internal/applog"
+ "hamlog/internal/pskr"
+)
+
+// keyChaseNew turns the widget on. Deliberately NOT nested under "chase grids":
+// chasing squares and chasing entities are different wants, and an operator may
+// have one without the other. They only share the feed, which either one starts.
+const keyChaseNew = "cluster.chase_new"
+
+// chaseNewMax bounds the panel. A list nobody can read to the bottom is not more
+// information, and the oldest rows are the least likely to still be on the air.
+const chaseNewMax = 200
+
+// chaseSeenTTL is how long the same station stays de-duplicated on one band and
+// mode. PSK Reporter re-reports a calling station every cycle — without this the
+// panel would be one operator repeated fifty times.
+const chaseSeenTTL = 10 * time.Minute
+
+// ChaseNewSpot is one station worth looking at, as the widget shows it.
+type ChaseNewSpot struct {
+ Call string `json:"call"`
+ Band string `json:"band"`
+ Mode string `json:"mode"`
+ FreqHz int64 `json:"freq_hz"`
+ Grid string `json:"grid"`
+ Country string `json:"country,omitempty"`
+ Cont string `json:"cont,omitempty"`
+ DistKm int `json:"dist_km"`
+ Bearing int `json:"bearing"`
+ // Status is the entity-level verdict from the cluster's own vocabulary:
+ // new | new-band | new-mode | new-slot. Empty when the row is here for a
+ // prefix or a square instead.
+ Status string `json:"status,omitempty"`
+ NewPfx bool `json:"new_pfx,omitempty"`
+ NewGrid bool `json:"new_grid,omitempty"`
+ LoTW bool `json:"lotw,omitempty"`
+ At string `json:"at"` // RFC3339, stamped on receipt
+}
+
+// chaseNewStore holds what the widget shows. Written from the MQTT goroutine,
+// read by the UI poll, so everything is behind one mutex — the work per message
+// is a handful of map lookups and this must never become the reason the broker's
+// buffer backs up.
+type chaseNewStore struct {
+ mu sync.Mutex
+ spots []ChaseNewSpot // newest last
+ seen map[string]time.Time // "CALL|BAND|MODE" → when it was last shown
+}
+
+func newChaseNewStore() *chaseNewStore {
+ return &chaseNewStore{seen: make(map[string]time.Time, 512)}
+}
+
+// put adds a spot unless the same station on the same band and mode is already
+// on the list. Returns false when it was a duplicate.
+func (s *chaseNewStore) put(sp ChaseNewSpot, now time.Time) bool {
+ key := sp.Call + "|" + sp.Band + "|" + sp.Mode
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if last, ok := s.seen[key]; ok && now.Sub(last) < chaseSeenTTL {
+ return false
+ }
+ s.seen[key] = now
+ s.spots = append(s.spots, sp)
+ if len(s.spots) > chaseNewMax {
+ s.spots = s.spots[len(s.spots)-chaseNewMax:]
+ }
+ // The de-duplication map is the only thing here that grows without a natural
+ // bound, so it is swept when it gets large rather than on every message.
+ if len(s.seen) > 4*chaseNewMax {
+ for k, t := range s.seen {
+ if now.Sub(t) >= chaseSeenTTL {
+ delete(s.seen, k)
+ }
+ }
+ }
+ return true
+}
+
+// list returns the spots newer than ttl, newest first.
+func (s *chaseNewStore) list(ttl time.Duration, now time.Time) []ChaseNewSpot {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([]ChaseNewSpot, 0, len(s.spots))
+ for _, sp := range s.spots {
+ at, err := time.Parse(time.RFC3339, sp.At)
+ if err == nil && ttl > 0 && now.Sub(at) > ttl {
+ continue
+ }
+ out = append(out, sp)
+ }
+ sort.SliceStable(out, func(i, j int) bool { return out[i].At > out[j].At })
+ return out
+}
+
+func (s *chaseNewStore) clear() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.spots = nil
+ s.seen = make(map[string]time.Time, 512)
+}
+
+// chaseNewEnabled reads the option. Called per message, so it reads the cached
+// atomic rather than the settings store.
+func (a *App) chaseNewEnabled() bool { return a.chaseNewOn.Load() }
+
+// refreshChaseNew re-reads the option into the atomic the feed consults.
+func (a *App) refreshChaseNew() {
+ on := a.settingOr(keyChaseNew, "") == "1"
+ a.chaseNewOn.Store(on)
+ if !on && a.chaseNew != nil {
+ // Drop the list rather than leave it on screen: it would go stale with no
+ // feed behind it, and a frozen list of "new" stations is worse than none.
+ a.chaseNew.clear()
+ }
+}
+
+// feedChaseNew turns one PSK Reporter decode into a widget row, or drops it.
+//
+// Runs on the MQTT goroutine. The cheap tests come first — the option, then the
+// de-duplication — so a station already listed costs one map lookup and nothing
+// else.
+func (a *App) feedChaseNew(sp pskr.Spot) {
+ if !a.chaseNewEnabled() || a.chaseNew == nil {
+ return
+ }
+ call := strings.ToUpper(strings.TrimSpace(sp.Call))
+ if call == "" {
+ return
+ }
+ band := strings.ToLower(strings.TrimSpace(sp.Band))
+ mode := strings.ToUpper(strings.TrimSpace(sp.Mode))
+
+ // The same verdict the cluster grid computes, from the same cached index:
+ // map lookups per spot, no query.
+ st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}})
+ if len(st) == 0 {
+ return
+ }
+ s := st[0]
+ isNew := s.Status == "new" || s.Status == "new-band" ||
+ s.Status == "new-mode" || s.Status == "new-slot" || s.NewPfx || s.NewGrid
+ if !isNew {
+ return
+ }
+
+ now := time.Now()
+ row := ChaseNewSpot{
+ Call: call, Band: band, Mode: mode, FreqHz: sp.FreqHz,
+ Grid: sp.Grid, Country: s.Country, Cont: s.Continent,
+ DistKm: sp.DistKm, Bearing: sp.Bearing,
+ Status: s.Status, NewPfx: s.NewPfx, NewGrid: s.NewGrid, LoTW: s.LoTW,
+ At: now.UTC().Format(time.RFC3339),
+ }
+ // The grid the watcher gives is the transmitter's own, straight off the air —
+ // better than anything we could look up, so it is kept even when the status
+ // index had one.
+ if row.Grid == "" {
+ row.Grid = s.Grid
+ }
+ a.chaseNew.put(row, now)
+}
+
+// GetChaseNewSpots returns what the widget should show, newest first, aged out
+// with the same spot lifetime the cluster and band maps use — one setting for
+// "how long is a spot worth looking at", not three.
+func (a *App) GetChaseNewSpots() []ChaseNewSpot {
+ if a.chaseNew == nil || !a.chaseNewEnabled() {
+ return []ChaseNewSpot{}
+ }
+ ttl := time.Duration(a.GetSpotTTLMinutes()) * time.Minute
+ return a.chaseNew.list(ttl, time.Now())
+}
+
+// GetChaseNew reports whether the widget is on.
+func (a *App) GetChaseNew() bool { return a.chaseNewEnabled() }
+
+// SetChaseNew turns the widget on or off and brings the feed up or down with it.
+func (a *App) SetChaseNew(on bool) error {
+ v := "0"
+ if on {
+ v = "1"
+ }
+ a.setSetting(keyChaseNew, v)
+ a.refreshChaseNew()
+ // The feed is shared: startBandOpenSources decides whether it is still needed
+ // by anything else, so turning this off does not cut the grid store loose.
+ a.startBandOpenFeed()
+ applog.Printf("chase new: %v", on)
+ return nil
+}
diff --git a/pskchase_test.go b/pskchase_test.go
new file mode 100644
index 0000000..1d69037
--- /dev/null
+++ b/pskchase_test.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "testing"
+ "time"
+)
+
+// The panel must not become one station repeated. PSK Reporter re-reports a
+// calling operator every cycle, and dozens of receivers report the same
+// transmission, so a station arrives many times a minute.
+func TestChaseNewStoreDeduplicates(t *testing.T) {
+ s := newChaseNewStore()
+ t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
+ row := ChaseNewSpot{Call: "VK9XX", Band: "20m", Mode: "FT8", At: t0.Format(time.RFC3339)}
+
+ if !s.put(row, t0) {
+ t.Fatal("the first sighting was rejected")
+ }
+ if s.put(row, t0.Add(2*time.Minute)) {
+ t.Error("the same station on the same band and mode was listed twice")
+ }
+ // A different band is a different opportunity — a new-band slot is exactly
+ // what an operator is watching for.
+ other := row
+ other.Band = "15m"
+ if !s.put(other, t0.Add(2*time.Minute)) {
+ t.Error("the same station on another band was suppressed")
+ }
+ // Once the window has passed it is worth showing again: the station is still
+ // there, and the row it had has aged out of the list.
+ if !s.put(row, t0.Add(chaseSeenTTL+time.Minute)) {
+ t.Error("the station never came back after the de-duplication window")
+ }
+}
+
+// The list is aged with the operator's own spot lifetime, so a station heard an
+// hour ago is not offered as something to chase now.
+func TestChaseNewStoreAgesOut(t *testing.T) {
+ s := newChaseNewStore()
+ t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
+ s.put(ChaseNewSpot{Call: "OLD", Band: "20m", Mode: "FT8", At: t0.Format(time.RFC3339)}, t0)
+ s.put(ChaseNewSpot{Call: "NEW", Band: "20m", Mode: "FT8",
+ At: t0.Add(20 * time.Minute).Format(time.RFC3339)}, t0.Add(20*time.Minute))
+
+ got := s.list(15*time.Minute, t0.Add(21*time.Minute))
+ if len(got) != 1 || got[0].Call != "NEW" {
+ t.Fatalf("got %+v, want only NEW", got)
+ }
+ // Newest first: an operator reads the top of this list and nothing else.
+ s.put(ChaseNewSpot{Call: "NEWEST", Band: "20m", Mode: "FT8",
+ At: t0.Add(25 * time.Minute).Format(time.RFC3339)}, t0.Add(25*time.Minute))
+ got = s.list(15*time.Minute, t0.Add(26*time.Minute))
+ if len(got) != 2 || got[0].Call != "NEWEST" {
+ t.Fatalf("got %+v, want NEWEST first", got)
+ }
+}
+
+// The list is bounded. A widget that grows without limit under a 6 m opening
+// costs memory for rows nobody will ever scroll to.
+func TestChaseNewStoreIsBounded(t *testing.T) {
+ s := newChaseNewStore()
+ t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
+ for i := 0; i < chaseNewMax+50; i++ {
+ at := t0.Add(time.Duration(i) * time.Second)
+ s.put(ChaseNewSpot{
+ Call: "S" + time.Duration(i).String(), Band: "20m", Mode: "FT8",
+ At: at.Format(time.RFC3339),
+ }, at)
+ }
+ if got := len(s.list(0, t0.Add(time.Hour))); got != chaseNewMax {
+ t.Errorf("kept %d rows, want the %d cap", got, chaseNewMax)
+ }
+}