From a9e3a159d934d2776f1d25cb118484534280d3ec Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Fri, 14 Aug 2026 15:20:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(cluster):=20Chase=20New=20=E2=80=94=20a=20?= =?UTF-8?q?panel=20for=20what=20is=20new=20and=20audible=20here?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PSK Reporter tells you what is actually being decoded in your region, which is a larger set than what somebody chose to spot: nobody spots the FT8 caller running ten watts from a rare square. Almost all of it existed. The MQTT payload already carries frequency, mode, transmitter and both grids; the watcher already drops any report collected further than NearKm from the operator, which is exactly the question worth asking — the station is being heard HERE, not in Japan; and with grid chasing on the subscription is already every band, filtered at the broker by receiver square, measured at 0.2 to 1.2 messages a second. This reads messages that were arriving and being discarded. "New" is not decided here. Every spot goes through ClusterSpotStatuses, the same function the DX cluster grid uses and the same cached index, so the two panels cannot drift apart the way the county columns did. Cost per message is map lookups behind an option cached in an atomic, because the MQTT goroutine must never wait on the settings store. The option is its own, not nested under grid chasing: chasing squares and chasing entities are different wants, and the feed now has three consumers, any one of which brings it up and none of which cuts the others loose when it goes down. The panel says "digital modes only" in its footer. An empty list has to mean "nothing new on FT8/FT4/JS8 near you", not "the band is dead" — it will never show a new entity on CW. --- app.go | 11 +- bandopen_sources.go | 23 ++- changelog.json | 6 +- frontend/src/App.tsx | 21 ++- frontend/src/components/ChaseNewPanel.tsx | 126 +++++++++++++ frontend/src/components/SettingsModal.tsx | 15 +- frontend/src/lib/i18n.tsx | 2 + frontend/wailsjs/go/main/App.d.ts | 6 + frontend/wailsjs/go/main/App.js | 12 ++ frontend/wailsjs/go/models.ts | 38 ++++ internal/pskr/pskr.go | 15 +- pskchase.go | 220 ++++++++++++++++++++++ pskchase_test.go | 73 +++++++ 13 files changed, 549 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/ChaseNewPanel.tsx create mode 100644 pskchase.go create mode 100644 pskchase_test.go 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 ( +
+
+ + {t('chn.title')} + + {spots.length > 0 ? t('chn.count', { n: spots.length }) : ''} + +
+ +
+ {!loaded ? ( +
+ {t('chn.loading')} +
+ ) : spots.length === 0 ? ( +

{t('chn.empty')}

+ ) : ( +
+ {spots.map((s, i) => { + const sk = statusKey(s); + return ( + + ); + })} +
+ )} +
+ +

{t('chn.digitalOnly')}

+
+ ); +} 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) + } +}