From 08d316b1e0af060de53cecfa640646c4b034d036 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 27 Aug 2026 21:33:17 +0200 Subject: [PATCH] feat(cluster): a SOTA column, and a spot click that credits the summit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SOTA feeds put the summit in the spot's own text, so there is nothing to poll: a regex on the comment gives the reference, and clicking the spot writes SOTA@ into the award references the way a park already wrote POTA@. The regex is narrow on purpose — association/region-NNN, anchored — because POTA (US-4475), WWFF (DLFF-0001) and portable callsigns (DL/SP9DPM/P) share that field and none of them is a summit. Pinned by a table test. Off by default: the reference is only there on the summit feeds, and an empty column for every operator who does not chase them is worse than a checkbox. --- app.go | 5 +++ changelog.json | 6 ++-- frontend/src/App.tsx | 26 ++++++++------- frontend/src/components/ClusterGrid.tsx | 11 +++++++ frontend/src/lib/i18n.tsx | 4 +-- internal/cluster/cluster.go | 44 ++++++++++++++----------- internal/cluster/cluster_test.go | 2 +- internal/cluster/sotaref.go | 21 ++++++++++++ internal/cluster/sotaref_test.go | 25 ++++++++++++++ 9 files changed, 108 insertions(+), 36 deletions(-) create mode 100644 internal/cluster/sotaref.go create mode 100644 internal/cluster/sotaref_test.go diff --git a/app.go b/app.go index c848a61..7dd2935 100644 --- a/app.go +++ b/app.go @@ -8931,6 +8931,11 @@ func (a *App) clusterEventWorker() { } } } + // SOTA: the summit is in the spot's own text — the SOTA feeds put it + // there — so it costs a regex rather than an API lookup. + if s.SOTARef == "" { + s.SOTARef = cluster.SOTARefFrom(s.Comment) + } // POTA: tag the spot when the DX station is currently activating a park. if a.pota != nil { if info, ok := a.pota.Lookup(s.DXCall); ok { diff --git a/changelog.json b/changelog.json index 04e0c00..51b86d5 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,15 @@ "Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.", "Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.", "Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radio’s own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle.", - "Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom." + "Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.", + "Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSO’s SOTA award reference, as a POTA spot already did. Turn the column on in Columns." ], "fr": [ "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", "Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.", "Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir.", - "Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom." + "Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.", + "Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0cc11a1..4f435bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3275,7 +3275,7 @@ export default function App() { function handleSpotSelect(s: any) { if (!s?.dx_call?.trim()) return; onCallsignInput(s.dx_call, { force: true }); - applySpotPOTA((s as any).pota_ref); + applySpotRefs((s as any).pota_ref, (s as any).sota_ref); } function handleSpotClick(s: any) { @@ -3300,7 +3300,7 @@ export default function App() { FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {}); if (m) applyModeFromSpot(m); onCallsignInput(s.dx_call, { force: true }); - applySpotPOTA((s as any).pota_ref); + applySpotRefs((s as any).pota_ref, (s as any).sota_ref); if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call); } @@ -3761,14 +3761,14 @@ export default function App() { restartRecordingForNewTarget(call); // The park, like a click in the band map: the radio reports only a // callsign, so the backend looks it up again before sending the event. - applySpotPOTA(String(p?.pota_ref ?? '')); + applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? '')); }); // Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex. const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => { const call = String(p?.call ?? ''); if (!applyUdpCall(call, true)) return; restartRecordingForNewTarget(call); - applySpotPOTA(String(p?.pota_ref ?? '')); + applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? '')); }); const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => { const total = Number(p?.total ?? 0); @@ -4796,13 +4796,17 @@ export default function App() { } wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150); } - // applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's - // park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh - // target). Routed to the pota_ref column at save via applyAwardRefs. - function applySpotPOTA(potaRef?: string) { - const refs = String(potaRef || '') - .split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean); - setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') })); + // Award references carried by the spot itself: the park a station is + // activating, the summit it is on, or both. Written into award_refs the same + // way for each, so logging the contact credits it without retyping a + // reference that was on screen. + function applySpotRefs(potaRef?: string, sotaRef?: string) { + const split = (v?: string) => String(v || '').split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean); + const refs = [ + ...split(potaRef).map((r) => `POTA@${r}`), + ...split(sotaRef).map((r) => `SOTA@${r}`), + ]; + setDetails((d) => ({ ...d, award_refs: refs.join(';') })); } function onCallsignInput(v: string, opts?: { force?: boolean }) { // Programmatic call-sets (force: spot click, UDP, external app) count as diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index fec3aaf..10a143c 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -45,6 +45,7 @@ export type ClusterSpot = { raw: string; repeats?: number; pota_ref?: string; + sota_ref?: string; pota_name?: string; }; @@ -336,6 +337,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ : { color: 'var(--success)' }) as any, tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined), }, + { + // SOTA sits next to POTA and reads the same way. The reference comes from the + // spot's comment, so it is present on the SOTA feeds and empty elsewhere — + // which is why the column is off by default rather than an empty column for + // everyone who does not watch summits. + group: 'Spot', label: t('clg2.c.sota'), colId: 'sota', + headerName: t('clg2.c.sota'), field: 'sota_ref' as any, width: 100, cellClass: 'font-mono', + defaultVisible: false, + cellStyle: () => ({ color: 'var(--success)' }) as any, + }, { group: 'Spot', label: t('clg2.c.freq'), colId: 'freq', headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono', diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index f4d4fcb..63bf40a 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -491,7 +491,7 @@ const en: Dict = { '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.allGroups': 'All groups:', 'wbg.all': 'all', 'wbg.none': 'none', 'wbg.grpAwards': 'Awards', 'wbg.resetDefaults': 'Reset to defaults', 'wbg.done': 'Done', 'chn.title': 'Chase new', 'chn.close': 'Hide the panel', 'chn.filterHint': 'Show or hide this kind', 'chn.allFiltered': 'Everything heard is filtered out — turn a category back on above.', 'chn.toggle': '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.allFiltered': '{n} spots received, none shown — your filters are hiding them all.', 'clg2.activeFilters': 'Active:', 'clg2.clearAllFilters': 'Clear every filter', 'clg2.fBandLock': 'band locked to the rig', 'clg2.fBands': 'bands {list}', 'clg2.fModeLock': 'mode locked to the rig', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'status chips', 'clg2.fHideWorked': 'hide worked', 'clg2.fLotwOnly': 'LoTW users only', 'clg2.fSpotterCont': 'spotter continent', 'clg2.fSource': 'one source node', 'clg2.fSearch': 'search “{q}”', - '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.newBandMode': 'NEW B+M', '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.newGridUnconf': 'GRID?', '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.tipNewBandMode': 'NEW BAND AND NEW MODE for this entity — neither has been worked with it', '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.newSpots': '{n} new spots — click to resume', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.allGroups': 'All groups:', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done', + 'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', '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.newBandMode': 'NEW B+M', '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.newGridUnconf': 'GRID?', '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.tipNewBandMode': 'NEW BAND AND NEW MODE for this entity — neither has been worked with it', '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.newSpots': '{n} new spots — click to resume', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.allGroups': 'All groups:', '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)', 'aud.phFromRadio': 'Rig audio output → soundcard input', 'aud.phToRadio': 'Soundcard output → rig mic/data in', 'aud.phRecMic': 'Your microphone (record voice-keyer messages)', 'aud.phListening': 'Local speakers for preview', @@ -963,7 +963,7 @@ const fr: Dict = { '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.allGroups': 'Tous les groupes :', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé', 'chn.title': 'Chasse au nouveau', 'chn.close': 'Masquer le panneau', 'chn.filterHint': 'Afficher ou masquer ce type', 'chn.allFiltered': 'Tout ce qui est entendu est filtré — réactivez une catégorie ci-dessus.', 'chn.toggle': '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.allFiltered': '{n} spots reçus, aucun affiché — vos filtres les masquent tous.', 'clg2.activeFilters': 'Actifs :', 'clg2.clearAllFilters': 'Effacer tous les filtres', 'clg2.fBandLock': 'bande verrouillée sur la radio', 'clg2.fBands': 'bandes {list}', 'clg2.fModeLock': 'mode verrouillé sur la radio', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'pastilles de statut', 'clg2.fHideWorked': 'masquer les contactés', 'clg2.fLotwOnly': 'utilisateurs LoTW uniquement', 'clg2.fSpotterCont': 'continent du spotteur', 'clg2.fSource': 'un seul nœud source', 'clg2.fSearch': 'recherche « {q} »', - '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.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', '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.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux n’a été fait avec elle', '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.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé', + 'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', '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.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', '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.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux n’a été fait avec elle', '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.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', '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)', 'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute', diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index d180c59..e5d85c5 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -44,30 +44,30 @@ type ServerConfig struct { // is emitted to the UI, so the table never has empty country cells // flickering in for a few hundred ms. type Spot struct { - SourceID int64 `json:"source_id"` // ID of the cluster server this came from - SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers) - Spotter string `json:"spotter"` // DE field + SourceID int64 `json:"source_id"` // ID of the cluster server this came from + SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers) + Spotter string `json:"spotter"` // DE field // SpotterContinent belongs to the SPOT, not to the DX station: one call is // spotted by dozens of skimmers on every continent within a minute. It is // resolved per spot at ingest for exactly that reason — see the note on the // spotter-continent filter in App.tsx. - SpotterContinent string `json:"spotter_continent,omitempty"` - DXCall string `json:"dx_call"` // the DX station heard - FreqKHz float64 `json:"freq_khz"` - FreqHz int64 `json:"freq_hz"` - Band string `json:"band,omitempty"` - Comment string `json:"comment,omitempty"` - Locator string `json:"locator,omitempty"` // spotter grid (optional) - TimeUTC string `json:"time_utc,omitempty"` - Country string `json:"country,omitempty"` // DXCC entity name (cty.dat) - Continent string `json:"continent,omitempty"` // 2-letter continent - CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone - ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone - DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid - ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator - LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360 - ReceivedAt time.Time `json:"received_at"` - Raw string `json:"raw"` + SpotterContinent string `json:"spotter_continent,omitempty"` + DXCall string `json:"dx_call"` // the DX station heard + FreqKHz float64 `json:"freq_khz"` + FreqHz int64 `json:"freq_hz"` + Band string `json:"band,omitempty"` + Comment string `json:"comment,omitempty"` + Locator string `json:"locator,omitempty"` // spotter grid (optional) + TimeUTC string `json:"time_utc,omitempty"` + Country string `json:"country,omitempty"` // DXCC entity name (cty.dat) + Continent string `json:"continent,omitempty"` // 2-letter continent + CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone + ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone + DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid + ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator + LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360 + ReceivedAt time.Time `json:"received_at"` + Raw string `json:"raw"` // Historical marks a spot recovered from a SH/DX table rather than heard live. // It belongs in the grid, but must NOT fire alerts or reach the panadapter: // replaying 100 past spots would spam both, and a station spotted three hours @@ -75,6 +75,10 @@ type Spot struct { Historical bool `json:"historical,omitempty"` POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app) POTAName string `json:"pota_name,omitempty"` // park name + // SOTARef comes from the COMMENT, not from an API: the SOTA clusters put the + // summit in the text of the spot they send ("W9/WI-001"), and there is no + // per-callsign endpoint to ask the way POTA has one. + SOTARef string `json:"sota_ref,omitempty"` } // State enumerates the per-server lifecycle. diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index a74b650..9b91174 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -110,7 +110,7 @@ func TestParseShowDX(t *testing.T) { // chatter turned into fake spots would be worse than no parser at all. func TestParseShowDXRejectsNoise(t *testing.T) { noise := []string{ - "DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it + "DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it "Hello and welcome to the DXSpider cluster", "WWV de VE7CC <18Z> : SFI=110, A=16, K=2", "F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >", diff --git a/internal/cluster/sotaref.go b/internal/cluster/sotaref.go new file mode 100644 index 0000000..18da707 --- /dev/null +++ b/internal/cluster/sotaref.go @@ -0,0 +1,21 @@ +package cluster + +import "regexp" + +// sotaRefRe matches a SOTA summit reference inside a spot comment. +// +// The shape is association/region-NNN — "W9/WI-001", "DM/BM-063", "VK3/VC-014", +// "F/AM-123" — and the association may carry digits. Anchored on both sides so +// a callsign like DL/SP9DPM/P can never be read as one, and deliberately +// narrower than "anything with a slash and a dash": POTA (US-4475) and WWFF +// (DLFF-0001) refs share the comment field and must not be caught here. +var sotaRefRe = regexp.MustCompile(`\b([A-Z0-9]{1,4}(?:/[A-Z0-9]{1,4})?/[A-Z]{2}-[0-9]{3})\b`) + +// SOTARefFrom returns the first SOTA reference in a spot comment, or "". +func SOTARefFrom(comment string) string { + m := sotaRefRe.FindStringSubmatch(comment) + if m == nil { + return "" + } + return m[1] +} diff --git a/internal/cluster/sotaref_test.go b/internal/cluster/sotaref_test.go new file mode 100644 index 0000000..a2e0d58 --- /dev/null +++ b/internal/cluster/sotaref_test.go @@ -0,0 +1,25 @@ +package cluster + +import "testing" + +func TestSOTARefFrom(t *testing.T) { + // Left column: real comments seen on the SOTA cluster feed. + cases := []struct{ in, want string }{ + {"W9/WI-001", "W9/WI-001"}, + {"DM/BM-063", "DM/BM-063"}, + {"W7Y/TT-122", "W7Y/TT-122"}, + {"VK3/VC-014 s2s", "VK3/VC-014"}, + {"[SOTA] F/AM-123 cq", "F/AM-123"}, + {"", ""}, + // The other reference schemes that share this field. + {"POTA US-4475", ""}, + {"WWFF DLFF-0001", ""}, + // A portable callsign is not a summit. + {"DL/SP9DPM/P calling", ""}, + } + for _, c := range cases { + if got := SOTARefFrom(c.in); got != c.want { + t.Errorf("SOTARefFrom(%q) = %q, want %q", c.in, got, c.want) + } + } +}