diff --git a/app.go b/app.go
index ec35dea..f8eb14f 100644
--- a/app.go
+++ b/app.go
@@ -788,10 +788,30 @@ func (a *App) applyStationDefaults(q *qso.QSO) {
v := *p.TxPower
q.TXPower = &v
}
- // Resolve my zones / lat / lon via cty.dat using the profile's
- // callsign. The profile only stores the human-friendly fields
- // (callsign, grid, country name); cty.dat fills the structured
- // DXCC metadata that the ADIF spec wants for every QSO.
+ // Profile-stored MY_* DXCC metadata wins (the user can override the
+ // auto-filled values in Station Information).
+ if q.MyDXCC == nil && p.MyDXCC != nil {
+ v := *p.MyDXCC
+ q.MyDXCC = &v
+ }
+ if q.MyCQZone == nil && p.MyCQZone != nil {
+ v := *p.MyCQZone
+ q.MyCQZone = &v
+ }
+ if q.MyITUZone == nil && p.MyITUZone != nil {
+ v := *p.MyITUZone
+ q.MyITUZone = &v
+ }
+ if q.MyLat == nil && p.MyLat != nil {
+ v := *p.MyLat
+ q.MyLat = &v
+ }
+ if q.MyLon == nil && p.MyLon != nil {
+ v := *p.MyLon
+ q.MyLon = &v
+ }
+ // Resolve any still-missing my zones / lat / lon via cty.dat using the
+ // profile's callsign — the fallback when the profile didn't store them.
if a.dxcc != nil && p.Callsign != "" {
if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
if q.MyCQZone == nil && m.CQZone != 0 {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 38958a1..c1d6595 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1288,14 +1288,14 @@ export default function App() {
@@ -1304,6 +1304,7 @@ export default function App() {
{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'}
setFreqFocused(true)}
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 19a74a4..b80d354 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -125,41 +125,6 @@ interface Props {
// the callsign + grid in the Station Information form. Debounces the
// backend resolver so we don't fire on every keystroke; refreshes when
// inputs change. Empty card when no callsign yet.
-function StationInfoComputedBadge({ callsign, grid }: { callsign: string; grid: string }) {
- const [info, setInfo] = useState<{
- country: string; dxcc: number; cqz: number; ituz: number; lat: number; lon: number;
- } | null>(null);
- useEffect(() => {
- const c = callsign.trim();
- if (!c) { setInfo(null); return; }
- const t = window.setTimeout(async () => {
- try {
- const i = await ComputeStationInfo(c, grid.trim());
- setInfo(i as any);
- } catch { setInfo(null); }
- }, 200);
- return () => window.clearTimeout(t);
- }, [callsign, grid]);
- if (!info || (!info.country && !info.cqz && !info.ituz)) {
- return null;
- }
- return (
-
-
- Auto-filled on each QSO (MY_*)
-
-
- {info.country && Country: {info.country}}
- {info.dxcc > 0 && DXCC#: {info.dxcc}}
- {info.cqz > 0 && CQ: {info.cqz}}
- {info.ituz > 0 && ITU: {info.ituz}}
- {info.lat !== 0 && Lat: {info.lat.toFixed(4)}}
- {info.lon !== 0 && Lon: {info.lon.toFixed(4)}}
-
-
- );
-}
-
/* ====== Tree definition ======
Section IDs are stable strings — adding new ones means adding a panel below.
`disabled: true` greys them out and shows the "coming soon" placeholder. */
@@ -499,6 +464,36 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
})();
}, []);
+ // Auto-fill the active profile's MY_* DXCC metadata from the station
+ // callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
+ // are derived values, so they always recompute when the callsign or grid
+ // changes — the user can still edit a field, it just re-populates when the
+ // source changes. Debounced so we don't hammer cty.dat while typing.
+ useEffect(() => {
+ const call = (activeProfile?.callsign ?? '').trim();
+ if (!call) return;
+ const grid = (activeProfile?.my_grid ?? '').trim();
+ const t = window.setTimeout(async () => {
+ try {
+ const i: any = await ComputeStationInfo(call, grid);
+ setActiveProfile((p) => {
+ if (!p) return p;
+ const patch: any = {};
+ if (i.country) patch.my_country = i.country;
+ if (i.dxcc) patch.my_dxcc = i.dxcc;
+ if (i.cqz) patch.my_cqz = i.cqz;
+ if (i.ituz) patch.my_ituz = i.ituz;
+ if (i.lat) patch.my_lat = i.lat;
+ if (i.lon) patch.my_lon = i.lon;
+ // Only re-render when a value actually changed (prevents loops).
+ const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]);
+ return changed ? { ...p, ...patch } : p;
+ });
+ } catch { /* offline / unknown prefix — leave fields as-is */ }
+ }, 250);
+ return () => window.clearTimeout(t);
+ }, [activeProfile?.callsign, activeProfile?.my_grid]);
+
// ── Band selection helpers (dual-list shuttle) ──────────────────────────
function addBand(tag: string) {
const b = tag.trim().toLowerCase();
@@ -681,7 +676,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).
-
+
+ Auto-filled from the callsign — editable (stamped as MY_* on each QSO)
+
updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
@@ -690,6 +687,33 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
updateActive({ my_country: e.target.value })} placeholder="France" />
+
updateActive({ my_state: e.target.value })} />
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 7d32caa..1890c26 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -741,6 +741,11 @@ export namespace profile {
my_pota_ref: string;
my_rig: string;
my_antenna: string;
+ my_dxcc?: number;
+ my_cqz?: number;
+ my_ituz?: number;
+ my_lat?: number;
+ my_lon?: number;
tx_pwr?: number;
is_active: boolean;
sort_order: number;
@@ -771,6 +776,11 @@ export namespace profile {
this.my_pota_ref = source["my_pota_ref"];
this.my_rig = source["my_rig"];
this.my_antenna = source["my_antenna"];
+ this.my_dxcc = source["my_dxcc"];
+ this.my_cqz = source["my_cqz"];
+ this.my_ituz = source["my_ituz"];
+ this.my_lat = source["my_lat"];
+ this.my_lon = source["my_lon"];
this.tx_pwr = source["tx_pwr"];
this.is_active = source["is_active"];
this.sort_order = source["sort_order"];
diff --git a/internal/db/migrations/0015_profile_my_zones.sql b/internal/db/migrations/0015_profile_my_zones.sql
new file mode 100644
index 0000000..f2b88f6
--- /dev/null
+++ b/internal/db/migrations/0015_profile_my_zones.sql
@@ -0,0 +1,9 @@
+-- Per-profile MY_* DXCC metadata. These were previously derived from the
+-- station callsign via cty.dat on every QSO; storing them on the profile
+-- lets the user see and override them in Station Information (auto-filled
+-- from the callsign, but editable for club/special situations).
+ALTER TABLE station_profiles ADD COLUMN my_dxcc INTEGER;
+ALTER TABLE station_profiles ADD COLUMN my_cqz INTEGER;
+ALTER TABLE station_profiles ADD COLUMN my_ituz INTEGER;
+ALTER TABLE station_profiles ADD COLUMN my_lat REAL;
+ALTER TABLE station_profiles ADD COLUMN my_lon REAL;
diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go
index ec821e7..e0a22fd 100644
--- a/internal/lookup/lookup.go
+++ b/internal/lookup/lookup.go
@@ -10,6 +10,7 @@ import (
"strings"
"sync"
"time"
+ "unicode"
)
// ErrNotFound is returned by providers when a callsign is unknown.
@@ -100,6 +101,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
// corrected entity mapping (e.g. Sicily → Italy) heal stale cached
// rows without waiting for the TTL to expire.
fillFromDXCC(&r, dxcc)
+ normalizeNames(&r)
return r, nil
}
@@ -111,6 +113,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
fillFromDXCC(&r, dxcc)
+ normalizeNames(&r)
_ = m.cache.Put(ctx, r)
return r, nil
}
@@ -145,6 +148,43 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
return Result{}, lastErr
}
+// normalizeNames title-cases the human-readable text fields so a QRZ/HamQTH
+// reply in ALL CAPS ("NOEL CHENAVARD", "VETRAZ-MONTHOUX") is stored and shown
+// consistently ("Noel Chenavard", "Vetraz-Monthoux"). State/zones/grid are
+// left untouched (codes like CT must stay as-is).
+func normalizeNames(r *Result) {
+ r.Name = titleCase(r.Name)
+ r.QTH = titleCase(r.QTH)
+ r.Address = titleCase(r.Address)
+}
+
+// titleCase lowercases the whole string then capitalises the first letter of
+// each word. Word boundaries are any non-alphanumeric rune (space, hyphen,
+// apostrophe, slash…), so "vetraz-monthoux" → "Vetraz-Monthoux" and
+// "o'brien" → "O'Brien". Digits never get a leading capital ("74140" stays).
+func titleCase(s string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return ""
+ }
+ runes := []rune(strings.ToLower(s))
+ atWordStart := true
+ for i, r := range runes {
+ switch {
+ case unicode.IsLetter(r):
+ if atWordStart {
+ runes[i] = unicode.ToUpper(r)
+ }
+ atWordStart = false
+ case unicode.IsDigit(r):
+ atWordStart = false
+ default:
+ atWordStart = true
+ }
+ }
+ return string(runes)
+}
+
// fillFromDXCC fills (or overrides) country/continent/zones/lat/lon from
// the cty.dat resolver. cty.dat is the authoritative source for DXCC
// mapping, so Country/Continent/CQZ/ITUZ are ALWAYS overridden when it
diff --git a/internal/lookup/normalize_test.go b/internal/lookup/normalize_test.go
new file mode 100644
index 0000000..3b7d8a3
--- /dev/null
+++ b/internal/lookup/normalize_test.go
@@ -0,0 +1,21 @@
+package lookup
+
+import "testing"
+
+func TestTitleCase(t *testing.T) {
+ cases := map[string]string{
+ "NOEL CHENAVARD": "Noel Chenavard",
+ "VETRAZ-MONTHOUX": "Vetraz-Monthoux",
+ "o'brien": "O'Brien",
+ "866 ROUTE DES VOIRONS": "866 Route Des Voirons",
+ "PARIS": "Paris",
+ "": "",
+ " saint-étienne ": "Saint-Étienne",
+ "JOHN": "John",
+ }
+ for in, want := range cases {
+ if got := titleCase(in); got != want {
+ t.Errorf("titleCase(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
diff --git a/internal/profile/profile.go b/internal/profile/profile.go
index d14be2a..e72365c 100644
--- a/internal/profile/profile.go
+++ b/internal/profile/profile.go
@@ -33,6 +33,11 @@ type Profile struct {
MyPOTARef string `json:"my_pota_ref"`
MyRig string `json:"my_rig"`
MyAntenna string `json:"my_antenna"`
+ MyDXCC *int `json:"my_dxcc,omitempty"`
+ MyCQZone *int `json:"my_cqz,omitempty"`
+ MyITUZone *int `json:"my_ituz,omitempty"`
+ MyLat *float64 `json:"my_lat,omitempty"`
+ MyLon *float64 `json:"my_lon,omitempty"`
TxPower *float64 `json:"tx_pwr,omitempty"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
@@ -48,7 +53,8 @@ func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
const selectCols = `id, name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
- my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at`
+ my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
+ is_active, sort_order, created_at, updated_at`
// List returns every profile, active first then by sort_order/id.
func (r *Repo) List(ctx context.Context) ([]Profile, error) {
@@ -96,11 +102,14 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
INSERT INTO station_profiles
(name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
- my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at)
- VALUES(?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?)`,
+ my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
+ is_active, sort_order, created_at, updated_at)
+ VALUES(?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?)`,
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
- p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), boolInt(p.IsActive), p.SortOrder, now, now)
+ p.MyRig, p.MyAntenna, nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
+ nullableFloat(p.MyLat), nullableFloat(p.MyLon), nullableFloat(p.TxPower),
+ boolInt(p.IsActive), p.SortOrder, now, now)
if err != nil {
return err
}
@@ -112,12 +121,15 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
UPDATE station_profiles SET
name = ?, callsign = ?, operator = ?, owner_callsign = ?, my_grid = ?, my_country = ?,
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
- my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?, tx_pwr = ?,
+ my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?,
+ my_dxcc = ?, my_cqz = ?, my_ituz = ?, my_lat = ?, my_lon = ?, tx_pwr = ?,
sort_order = ?, updated_at = ?
WHERE id = ?`,
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry,
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
- p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, nullableFloat(p.TxPower),
+ p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna,
+ nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
+ nullableFloat(p.MyLat), nullableFloat(p.MyLon), nullableFloat(p.TxPower),
p.SortOrder, now, p.ID)
return err
}
@@ -210,13 +222,15 @@ func scan(row scannable) (Profile, error) {
callsign, operator, ownerCall, myGrid, myCountry, myState, myCnty,
myStreet, myCity, myPostal, mySOTA, myPOTA,
myRig, myAntenna sql.NullString
- txPwr sql.NullFloat64
+ myDXCC, myCQZ, myITUZ sql.NullInt64
+ myLat, myLon, txPwr sql.NullFloat64
isActive, sortOrder int
createdAt, updatedAt string
)
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
- &myRig, &myAntenna, &txPwr, &isActive, &sortOrder, &createdAt, &updatedAt)
+ &myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
+ &isActive, &sortOrder, &createdAt, &updatedAt)
if err != nil {
return p, err
}
@@ -234,6 +248,26 @@ func scan(row scannable) (Profile, error) {
p.MyPOTARef = myPOTA.String
p.MyRig = myRig.String
p.MyAntenna = myAntenna.String
+ if myDXCC.Valid {
+ v := int(myDXCC.Int64)
+ p.MyDXCC = &v
+ }
+ if myCQZ.Valid {
+ v := int(myCQZ.Int64)
+ p.MyCQZone = &v
+ }
+ if myITUZ.Valid {
+ v := int(myITUZ.Int64)
+ p.MyITUZone = &v
+ }
+ if myLat.Valid {
+ v := myLat.Float64
+ p.MyLat = &v
+ }
+ if myLon.Valid {
+ v := myLon.Float64
+ p.MyLon = &v
+ }
if txPwr.Valid {
v := txPwr.Float64
p.TxPower = &v
@@ -251,6 +285,12 @@ func nullableFloat(p *float64) any {
}
return *p
}
+func nullableInt(p *int) any {
+ if p == nil {
+ return nil
+ }
+ return *p
+}
func boolInt(b bool) int {
if b {
return 1