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