feat(spot): put the award references in the spot comment

After the mode, which is the order a cluster line is read in:
"CW POTA FR-11553".

Two sources, because the QSO can be at either stage. While it is being typed
the entry panel holds the references as "CODE@REF;CODE@REF"; once logged they
live on the row as the materialised award_refs. The Send Spot window takes the
entry's when there are any and falls back to the last logged QSO — the same
fallback the callsign and frequency defaults already use — and picking a QSO
from the Latest list fills in that QSO's own.

A self-spot is the opposite case and gets its own builder: it announces OUR
station, so it carries MY_POTA_REF and friends. Using the QSO's award
references there would spot us with the park number of the station we just
worked, announcing us from somewhere we are not.

Both cap at the 30 characters a cluster node keeps, and add a reference whole
or not at all — a truncated park number is worse than none, since nobody can
act on it, and it still costs everyone who reads the spot. Only the comment we
BUILD is held to that; what the operator types is their own business.
This commit is contained in:
2026-08-14 13:22:21 +02:00
parent db87ef39c0
commit 4f9a366884
6 changed files with 159 additions and 8 deletions
+35 -1
View File
@@ -17016,6 +17016,40 @@ func (a *App) SaveSelfSpotSettings(s SelfSpotSettings) error {
// Fires on the FIRST QSO of a frequency, then at most once per configured gap. // Fires on the FIRST QSO of a frequency, then at most once per configured gap.
// Called from both log paths (manual entry and UDP auto-log), off the critical // Called from both log paths (manual entry and UDP auto-log), off the critical
// path — a cluster that is slow or down must never hold up logging. // path — a cluster that is slow or down must never hold up logging.
// spotCommentMax is the DX cluster comment field: 30 characters, and nodes
// silently drop what does not fit.
const spotCommentMax = 30
// selfSpotComment builds the comment for a spot of OUR OWN station: the mode,
// then the references WE are activating.
//
// MY_POTA_REF and friends, deliberately — not the QSO's award references, which
// belong to the station we worked. Spotting yourself with the other operator's
// park number announces you from a place you are not.
//
// A reference goes in whole or not at all: half a park number on the cluster is
// worse than none, since nobody can act on it.
func selfSpotComment(q qso.QSO) string {
out := strings.ToUpper(strings.TrimSpace(q.Mode))
for _, r := range []struct{ code, ref string }{
{"POTA", q.MyPOTARef},
{"SOTA", q.MySOTARef},
{"WWFF", q.MyWWFFRef},
{"IOTA", q.MyIOTA},
} {
ref := strings.ToUpper(strings.TrimSpace(r.ref))
if ref == "" {
continue
}
next := strings.TrimSpace(out + " " + r.code + " " + ref)
if len(next) > spotCommentMax {
continue // a shorter reference may still fit
}
out = next
}
return out
}
func (a *App) maybeSelfSpot(q qso.QSO) { func (a *App) maybeSelfSpot(q qso.QSO) {
cfg, err := a.GetSelfSpotSettings() cfg, err := a.GetSelfSpotSettings()
if err != nil || !cfg.Enabled { if err != nil || !cfg.Enabled {
@@ -17051,7 +17085,7 @@ func (a *App) maybeSelfSpot(q qso.QSO) {
a.selfSpotMu.Unlock() a.selfSpotMu.Unlock()
khz := math.Round(float64(hz)/100) / 10 // 0.1 kHz — the resolution a cluster keeps khz := math.Round(float64(hz)/100) / 10 // 0.1 kHz — the resolution a cluster keeps
if err := a.SendClusterSpot(call, khz, strings.ToUpper(strings.TrimSpace(q.Mode))); err != nil { if err := a.SendClusterSpot(call, khz, selfSpotComment(q)); err != nil {
// Not sent (no enabled server, node not connected). Restore the previous // Not sent (no enabled server, node not connected). Restore the previous
// state so the NEXT QSO tries again, instead of sitting out a gap that // state so the NEXT QSO tries again, instead of sitting out a gap that
// never produced a spot. // never produced a spot.
+4 -2
View File
@@ -9,7 +9,8 @@
"Appearance: new Sahara theme, warm sand tones for long sessions in daylight.", "Appearance: new Sahara theme, warm sand tones for long sessions in daylight.",
"US counties: Connecticut returned the census planning regions, and San Francisco, Doña Ana, Baltimore city and several Alaska boroughs matched nothing at all. The cluster now shows the same county as the Info panel. Re-download the county database.", "US counties: Connecticut returned the census planning regions, and San Francisco, Doña Ana, Baltimore city and several Alaska boroughs matched nothing at all. The cluster now shows the same county as the Info panel. Re-download the county database.",
"QSL: the manager (QSL_VIA) and the sending method (QSL_SENT_VIA / QSL_RCVD_VIA) are separate fields at last, as ADIF defines them. Imports keep both, exports write both, and a log where they were mixed is offered a correction at startup.", "QSL: the manager (QSL_VIA) and the sending method (QSL_SENT_VIA / QSL_RCVD_VIA) are separate fields at last, as ADIF defines them. Imports keep both, exports write both, and a log where they were mixed is offered a correction at startup.",
"WinKeyer: the opening handshake follows K1ELs own sequence (RTS off, parser resync, echo test), which wakes keyers that stayed silent, and a port where nothing answers now says so instead of showing “connected”. A keyer that reboots when the port opens — K3NG and other Arduino builds — is waited out and remembered, so it opens directly next time." "WinKeyer: the opening handshake follows K1ELs own sequence (RTS off, parser resync, echo test), which wakes keyers that stayed silent, and a port where nothing answers now says so instead of showing “connected”. A keyer that reboots when the port opens — K3NG and other Arduino builds — is waited out and remembered, so it opens directly next time.",
"Send Spot: the comment now carries the award references after the mode — the ones on the QSO being entered, or on the logged QSO you pick from the list. A self-spot carries your OWN activation references (my POTA/SOTA/WWFF/IOTA) instead."
], ],
"fr": [ "fr": [
"Amplificateurs : cochez ceux qui partagent un combineur et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses propres mesures.", "Amplificateurs : cochez ceux qui partagent un combineur et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses propres mesures.",
@@ -18,7 +19,8 @@
"Apparence : nouveau thème Sahara, tons sable chauds pour les longues sessions en plein jour.", "Apparence : nouveau thème Sahara, tons sable chauds pour les longues sessions en plein jour.",
"Comtés US : le Connecticut renvoyait les planning regions du recensement, et San Francisco, Doña Ana, Baltimore city et plusieurs districts dAlaska ne correspondaient à rien. Le cluster affiche maintenant le même comté que le panneau Info. Rechargez la base des comtés.", "Comtés US : le Connecticut renvoyait les planning regions du recensement, et San Francisco, Doña Ana, Baltimore city et plusieurs districts dAlaska ne correspondaient à rien. Le cluster affiche maintenant le même comté que le panneau Info. Rechargez la base des comtés.",
"QSL : le manager (QSL_VIA) et le mode denvoi (QSL_SENT_VIA / QSL_RCVD_VIA) sont enfin deux champs distincts, comme le veut lADIF. Les imports gardent les deux, les exports les écrivent, et un log où ils étaient mélangés se voit proposer une correction au démarrage.", "QSL : le manager (QSL_VIA) et le mode denvoi (QSL_SENT_VIA / QSL_RCVD_VIA) sont enfin deux champs distincts, comme le veut lADIF. Les imports gardent les deux, les exports les écrivent, et un log où ils étaient mélangés se voit proposer une correction au démarrage.",
"WinKeyer : la séquence douverture suit celle de K1EL (RTS bas, resynchronisation, test d’écho), ce qui réveille les manipulateurs muets, et un port où rien ne répond le dit au lieu dafficher « connecté ». Un manipulateur qui redémarre à louverture du port — K3NG et autres montages Arduino — est attendu puis mémorisé, pour souvrir directement ensuite." "WinKeyer : la séquence douverture suit celle de K1EL (RTS bas, resynchronisation, test d’écho), ce qui réveille les manipulateurs muets, et un port où rien ne répond le dit au lieu dafficher « connecté ». Un manipulateur qui redémarre à louverture du port — K3NG et autres montages Arduino — est attendu puis mémorisé, pour souvrir directement ensuite.",
"Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles du QSO en cours de saisie, ou du QSO choisi dans la liste. Un auto-spot porte VOS références dactivation (mes POTA/SOTA/WWFF/IOTA)."
] ]
}, },
{ {
+18 -1
View File
@@ -55,7 +55,7 @@ import {
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair, QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList } from '@/lib/awardRefs';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime'; import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -2086,6 +2086,19 @@ export default function App() {
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0'); const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Award references offered to the Send Spot window, as cluster-ready strings.
//
// The entry panel holds them as "CODE@REF;CODE@REF" while the QSO is being
// typed; once it is logged they live on the row as the materialised award_refs
// object. Take the entry's when there is one — spotting usually happens with
// the station still in the field — and fall back to the last logged QSO, which
// is where the callsign and frequency defaults come from too.
const spotEntryRefs = useMemo(() => {
const entry = spotRefList(parseManualRefs(details.award_refs ?? ''));
if (entry.length) return entry;
return spotRefList(parseAwardRefs(qsos[0]?.award_refs));
}, [details.award_refs, qsos]);
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
// picked award references to the QSO field/extras each award actually reads. // picked award references to the QSO field/extras each award actually reads.
const awardFieldRef = useRef<Record<string, string>>({}); const awardFieldRef = useRef<Record<string, string>>({});
@@ -7216,6 +7229,9 @@ export default function App() {
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0) : (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
} }
defaultMode={mode || qsos[0]?.mode || ''} defaultMode={mode || qsos[0]?.mode || ''}
// Award references: the ones on the QSO being entered, else those already
// logged for the last QSO — the same source the callsign above falls back to.
defaultRefs={spotEntryRefs}
targetName={ targetName={
clusterServers clusterServers
.filter((s) => s.enabled) .filter((s) => s.enabled)
@@ -7226,6 +7242,7 @@ export default function App() {
freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0, freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0,
mode: q.mode ?? '', mode: q.mode ?? '',
band: q.band, band: q.band,
refs: spotRefList(parseAwardRefs(q.award_refs)),
}))} }))}
onSend={async (call, freqKHz, comment) => { onSend={async (call, freqKHz, comment) => {
await SendClusterSpot(call, freqKHz, comment); await SendClusterSpot(call, freqKHz, comment);
+33 -4
View File
@@ -13,6 +13,32 @@ export interface RecentSpotQSO {
freqKHz: number; freqKHz: number;
mode: string; mode: string;
band?: string; band?: string;
// Award references logged for this QSO, each one complete and ready to read
// on a cluster line ("POTA FR-11553"). Empty for an ordinary contact.
refs?: string[];
}
// SPOT_COMMENT_MAX is the DX cluster comment field: 30 characters, and nodes
// truncate what does not fit without saying so. Only the comment BUILT here is
// held to it — whatever the operator types is their own business.
const SPOT_COMMENT_MAX = 30;
// spotComment puts the award references after the mode, the order a cluster
// line is read in: "CW POTA FR-11553".
//
// A reference goes in whole or not at all. Truncation would leave "CW POTA" or
// half a park number on the air, which is worse than saying nothing: a spot
// nobody can act on still costs everyone who reads it.
export function spotComment(mode: string, refs?: string[]): string {
let out = (mode || '').trim();
for (const ref of refs ?? []) {
const one = ref.trim();
if (!one) continue;
const next = out ? `${out} ${one}` : one;
if (next.length > SPOT_COMMENT_MAX) continue; // a shorter one may still fit
out = next;
}
return out;
} }
interface Props { interface Props {
@@ -23,6 +49,9 @@ interface Props {
defaultCall: string; defaultCall: string;
defaultFreqKHz: number; defaultFreqKHz: number;
defaultMode: string; defaultMode: string;
// Award references on the QSO being entered (or on the last logged one),
// appended to the comment after the mode.
defaultRefs?: string[];
// Master cluster name, shown so the user knows where the spot goes. // Master cluster name, shown so the user knows where the spot goes.
targetName?: string; targetName?: string;
recent: RecentSpotQSO[]; recent: RecentSpotQSO[];
@@ -33,7 +62,7 @@ interface Props {
// the master cluster: callsign + frequency (kHz) + a free message (defaults // the master cluster: callsign + frequency (kHz) + a free message (defaults
// to the mode). A "Latest QSOs" list lets the operator one-click a recent // to the mode). A "Latest QSOs" list lets the operator one-click a recent
// contact into the form. // contact into the form.
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) { export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, defaultRefs, targetName, recent, onSend }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [call, setCall] = useState(''); const [call, setCall] = useState('');
const [freqKHz, setFreqKHz] = useState(''); const [freqKHz, setFreqKHz] = useState('');
@@ -48,12 +77,12 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
if (!open) return; if (!open) return;
setCall((defaultCall || '').toUpperCase()); setCall((defaultCall || '').toUpperCase());
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : ''); setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
setMessage(defaultMode || ''); setMessage(spotComment(defaultMode, defaultRefs));
setError(''); setError('');
setOk(false); setOk(false);
// Focus the freq if the call is already known, else the call. // Focus the freq if the call is already known, else the call.
setTimeout(() => callRef.current?.focus(), 50); setTimeout(() => callRef.current?.focus(), 50);
}, [open, defaultCall, defaultFreqKHz, defaultMode]); }, [open, defaultCall, defaultFreqKHz, defaultMode, defaultRefs]);
async function send() { async function send() {
const c = call.trim().toUpperCase(); const c = call.trim().toUpperCase();
@@ -77,7 +106,7 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
function pick(q: RecentSpotQSO) { function pick(q: RecentSpotQSO) {
setCall(q.callsign.toUpperCase()); setCall(q.callsign.toUpperCase());
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz)); if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
if (q.mode) setMessage(q.mode); setMessage(spotComment(q.mode, q.refs));
setError(''); setError('');
} }
+17
View File
@@ -154,3 +154,20 @@ export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field:
} }
return out.join(';'); return out.join(';');
} }
// spotRefList turns a code→ref map into the strings a DX-cluster comment wants:
// ["POTA FR-11553", "IOTA EU-064"]. A code carrying several references (a
// two-fer park activation) yields one entry per reference, so a spot can carry
// the one that fits and drop the rest rather than emitting a truncated pair.
//
// Shortest first: cluster comments are 30 characters, and when they do not all
// fit, more references beat fewer.
export function spotRefList(byCode: Record<string, string>): string[] {
const out: string[] = [];
for (const [code, refs] of Object.entries(byCode ?? {})) {
for (const ref of String(refs).split(',').map((s) => s.trim()).filter(Boolean)) {
out.push(`${code.toUpperCase()} ${ref.toUpperCase()}`);
}
}
return out.sort((a, b) => a.length - b.length || a.localeCompare(b));
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"testing"
"hamlog/internal/qso"
)
// A self-spot announces OUR station, so it must carry the references we are
// activating (MY_POTA_REF…), never the QSO's award references — those belong to
// the station we worked, and spotting yourself with someone else's park number
// puts you somewhere you are not.
func TestSelfSpotComment(t *testing.T) {
for name, c := range map[string]struct {
q qso.QSO
want string
}{
"mode only": {
q: qso.QSO{Mode: "cw"},
want: "CW",
},
"my park": {
q: qso.QSO{Mode: "SSB", MyPOTARef: "fr-11553"},
want: "SSB POTA FR-11553",
},
"two references": {
q: qso.QSO{Mode: "CW", MyPOTARef: "FR-11553", MySOTARef: "F/AM-123"},
want: "CW POTA FR-11553 SOTA F/AM-123",
},
// 30 characters is all a cluster keeps. The third reference is dropped
// whole rather than cut in half.
"too long drops the extra": {
q: qso.QSO{
Mode: "CW", MyPOTARef: "FR-11553", MySOTARef: "F/AM-123",
MyWWFFRef: "FFF-0123",
},
want: "CW POTA FR-11553 SOTA F/AM-123",
},
// A QSO's own award references are the DX's, and must not leak in.
"dx references are not ours": {
q: qso.QSO{Mode: "FT8", POTARef: "US-0001", AwardRefs: `{"POTA":"US-0001"}`},
want: "FT8",
},
} {
if got := selfSpotComment(c.q); got != c.want {
t.Errorf("%s: got %q, want %q", name, got, c.want)
}
if got := selfSpotComment(c.q); len(got) > spotCommentMax {
t.Errorf("%s: %d characters, over the %d a cluster keeps", name, len(got), spotCommentMax)
}
}
}