Compare commits

...
10 Commits
Author SHA1 Message Date
rouggy c9d1fc1cc0 chore: release v0.21.8 2026-07-28 19:10:14 +02:00
rouggy f43d41892e docs: changelog — the QRZ date window covers both cases 2026-07-28 15:56:37 +02:00
rouggy 1f2e86c04d feat: a typed QRZ start date fetches that period, not what QRZ edited in it
"Last download" and an operator-chosen date are different questions, and they
now ask QRZ different ones. The automatic run wants everything TOUCHED since it
last ran (MODSINCE) — that is what returns a 2015 QSO confirmed yesterday. A date
typed by the operator names a period of OPERATING, so it maps to BETWEEN date and
today, on the QSO date.

The client-side date filter is skipped only under MODSINCE, where it would throw
away the older-but-newly-confirmed records the query exists to fetch. Under
BETWEEN it agrees with the server and stays on as a backstop.
2026-07-28 15:48:00 +02:00
rouggy 305da583e1 fix: QRZ download used an option QRZ does not accept
The date window was sent as OPTION=AFTER:<date>. That was a guess, and QRZ
rejects the request outright — RESULT=FAIL with no reason — so the confirmation
download failed completely for everyone, whatever the account.

The documented option is MODSINCE, and it is the better window anyway: it selects
records MODIFIED since the date, so a QSO from years ago that was confirmed
yesterday comes back. A QSO-date window structurally cannot return those, and
confirmations are the whole point of this download — which is why the client-side
date filter is now skipped when the server did the windowing, instead of throwing
those same records away on arrival.

A refusal now falls back to ALL rather than leaving the operator with nothing.
2026-07-28 15:43:47 +02:00
rouggy 71bde30e24 chore: log the window geometry that is saved and restored
Reported as a regression — reopens on the wrong screen — but the window code is
untouched since it was verified working: the only change to main.go since is the
background colour. So the fault is in the DATA, and there was no way to see it.

Both ends now log their coordinates, plus where the window actually landed after
the un-maximise / move / re-maximise dance. That separates the cases, which need
opposite fixes: nothing was captured, something wrong was captured, or the right
corner was captured and Windows moved the window anyway.
2026-07-28 15:35:57 +02:00
rouggy 31b13cfbc0 Revert "fix: no right-click Cut/Copy/Paste in release builds"
Reverts 30d8827. Ctrl+C / Ctrl+V already work in the app, so the WebView's
default menu only added Refresh / Save as on the neutral areas — noise in an
application window, for nothing gained. Changelog entry dropped with it.
2026-07-28 15:33:30 +02:00
rouggy 30d88276f3 fix: no right-click Cut/Copy/Paste in release builds
Wails disables the WebView context menu in production, so an operator could not
paste a cluster address, an API key or a callsign anywhere in the app. Ctrl+V
did work — nothing intercepts it — but the menu is where people look, and a key
copied off a web page is exactly what you paste rather than retype.

EnableDefaultContextMenu turns it back on.
2026-07-28 15:28:51 +02:00
rouggy b18db7acfc fix: a port field could not be cleared
Every port box was parseInt(e.target.value) || <default>. Delete the contents
and parseInt gives NaN, so the default is written straight back: the digits
reappear under the cursor and the only way to enter a different port is to
overwrite it character by character. Reported on the cluster editor; the same
line existed in eight other places.

One PortInput component now holds the raw TEXT as its state and only tells the
parent about a value that is actually a port. An empty box stays empty while you
type; on blur an invalid one falls back to the default, so nothing is ever saved
as 0 or NaN. It adopts a value arriving from the backend only while the operator
has not started typing — re-syncing mid-edit is the behaviour being fixed.

This is the controlled-input trap the project notes already warn about: keep the
raw text in local state and derive the stored value from it.
2026-07-28 15:25:21 +02:00
rouggy d303dee768 chore: microwave-band entry belongs to 0.21.8, not the published 0.21.7
Same slip as before the 0.21.6 release: I appended to a block that had already
shipped. 0.21.7 is back to its 12 published entries; the microwave-band fix
opens 0.21.8.
2026-07-28 15:15:37 +02:00
rouggy ad02583ef7 fix: recognise the microwave bands — 10 GHz resolved to no band at all
Reported on 3 cm. cat.BandFromHz stopped at 23 cm, so a 10 GHz frequency came
back EMPTY — and an empty band is not cosmetic: the QSO is logged without one,
counts in no award slot, and exports with no BAND field. The low end was short
too (2190m / 630m / 560m).

Four band tables had to be extended because four exist: the CAT one, the award
plan in app.go, the cluster spot classifier, and the frontend's. Plus the places
that LIST bands — the QSO editor and award-definition pickers (you could not set
3 cm by hand either), the statistics axis, and the award band ORDER, where a
missing band sorts to the end instead of in frequency order.

Ranges and names are ADIF 3.1.7, which is what an export has to carry.

A test now pins one frequency per band across the Go tables and asserts they
agree — that is what was missing, four hand-maintained copies with nothing
checking them. It also pins that an out-of-band frequency stays empty rather
than snapping to the nearest band, which would file a QSO under a band the
operator never used.
2026-07-28 14:50:17 +02:00
12 changed files with 322 additions and 75 deletions
+64 -12
View File
@@ -1653,6 +1653,11 @@ func (a *App) saveWindowState() {
x, y := wruntime.WindowGetPosition(a.ctx)
ws.X, ws.Y = x, y
}
// Logged because this is the only evidence of WHAT was stored: an operator
// reporting "it reopens on the wrong screen" cannot tell a position that was
// never captured from one that was captured wrong, and the two need opposite
// fixes.
applog.Printf("window: saving %d,%d %dx%d maximised=%v compact=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised, a.compact)
writeWindowState(a.dataDir, ws)
}
@@ -1667,15 +1672,20 @@ func (a *App) restoreWindowPosition() {
}
ws, ok := readWindowState(a.dataDir)
if !ok {
applog.Printf("window: no saved geometry — opening where Windows puts it")
return
}
applog.Printf("window: restoring %d,%d %dx%d maximised=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised)
// Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise,
// so a two-screen operator lost OpsLog to the wrong monitor at every launch.
// Un-maximise, move to the saved corner (which names the monitor), maximise
// again — all while the window is still hidden, so nothing flickers.
if ws.Maximised {
if ws.X == 0 && ws.Y == 0 {
return // never recorded (or genuinely the primary corner) nothing to do
// Either never recorded, or genuinely the primary monitor's corner —
// indistinguishable, and both mean "leave it to Windows".
applog.Printf("window: maximised with corner 0,0 — nothing to restore")
return
}
if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) {
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
@@ -1684,6 +1694,8 @@ func (a *App) restoreWindowPosition() {
wruntime.WindowUnmaximise(a.ctx)
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
wruntime.WindowMaximise(a.ctx)
gx, gy := wruntime.WindowGetPosition(a.ctx)
applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
return
}
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
@@ -3846,7 +3858,9 @@ type AwardStatsResult struct {
Rows []AwardStatRow `json:"rows"`
}
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"}
var statsBands = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm", "9cm", "6cm", "3cm", "1.25cm",
"6mm", "4mm", "2.5mm", "2mm", "1mm"}
// awardSnapshot returns the logbook as a light-scanned, award-enriched slice,
// reused across award computations. Pulling the whole logbook is the dominant
@@ -4176,7 +4190,11 @@ var awardBandPlan = []struct {
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
{"13cm", 2300000000, 2450000000},
{"13cm", 2300000000, 2450000000}, {"9cm", 3300000000, 3500000000},
{"6cm", 5650000000, 5925000000}, {"3cm", 10000000000, 10500000000},
{"1.25cm", 24000000000, 24250000000}, {"6mm", 47000000000, 47200000000},
{"4mm", 75500000000, 81000000000}, {"2.5mm", 119980000000, 123000000000},
{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
}
func bandForHz(hz int64) string {
@@ -9557,21 +9575,52 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
// QSO date. sinceDate is "YYYY-MM-DD".
sinceDate := resolveSince(keyExtQRZLastDownload)
emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload))
if sinceDate != "" {
emit("Fetching QRZ.com logbook (will skip QSOs before " + sinceDate + ")…")
} else {
emit("Fetching QRZ.com logbook (full — no since date)…")
}
// An automatic run ("last") and an operator-chosen date do not mean the
// same thing, so they ask QRZ two different questions:
// • "last" → MODSINCE: everything TOUCHED since the last run,
// which is what catches a 2015 QSO confirmed yesterday.
// • a typed date → BETWEEN: the QSOs MADE from that date to today. The
// operator asked for a period of operating, not for
// whatever QRZ happened to edit in that period.
autoWindow := strings.EqualFold(strings.TrimSpace(since), "last")
// Ask QRZ for the WINDOW rather than the whole logbook. "ALL" made the
// server serialise every QSO — 56 MB for a 117k-record log — which it
// truncates mid-record, so the parse died two thirds of the way through
// and everything past that point was never seen. AFTER: narrows it at the
// source; the client-side date filter below stays as the backstop.
fetchOpt := "ALL"
if sinceDate != "" {
fetchOpt = "AFTER:" + sinceDate
//
// The option is MODSINCE (QRZ FETCH documentation) — "AFTER:" was a guess
// and QRZ rejected the whole fetch, which broke the download outright.
// MODSINCE is also the RIGHT window for this job: it selects records
// MODIFIED since the date, so a ten-year-old QSO confirmed yesterday comes
// back — a QSO-date window would never return it, and confirmations are
// exactly what this download is for.
//
// It stays an optimisation, never a requirement: if QRZ refuses it, fall
// back to ALL rather than leaving the operator with no download at all.
//
// serverWindowed is set only for MODSINCE, because it alone returns QSOs
// OLDER than the date — the client-side date filter would drop exactly
// those. Under BETWEEN the filter agrees with the server and stays on as a
// backstop.
fetchOpt, serverWindowed := "ALL", false
switch {
case sinceDate == "":
emit("Fetching QRZ.com logbook (full — no since date)…")
case autoWindow:
fetchOpt, serverWindowed = "MODSINCE:"+sinceDate, true
emit("Fetching QRZ.com logbook (records modified since " + sinceDate + ")…")
default:
today := time.Now().UTC().Format("2006-01-02")
fetchOpt = "BETWEEN:" + sinceDate + "+" + today
emit("Fetching QRZ.com logbook (QSOs from " + sinceDate + " to " + today + ")…")
}
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
if err != nil && fetchOpt != "ALL" {
emit("QRZ.com refused " + fetchOpt + " (" + err.Error() + ") — fetching the full logbook instead…")
fetchOpt, serverWindowed = "ALL", false
fr, err = extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
}
if err != nil {
emit("Fetch failed: " + err.Error())
done(matched, total)
@@ -9631,7 +9680,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
return nil
}
// Date window (client-side): skip QSOs older than the requested date.
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
// ONLY when the server did not window the fetch itself — MODSINCE
// returns old QSOs whose CONFIRMATION is recent, and filtering those
// out by QSO date would throw away the very records asked for.
if !serverWindowed && sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
return nil
}
// Skip a QSO logged under a DIFFERENT one of the operator's callsigns.
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"testing"
"hamlog/internal/cat"
)
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
// logged, not counted in any award slot, and exports without a BAND field.
//
// One frequency per band, taken inside the range, plus the edges that used to be
// missing entirely.
func TestBandPlansAgree(t *testing.T) {
cases := []struct {
hz int64
want string
}{
{137000, "2190m"},
{475000, "630m"},
{1840000, "160m"},
{3650000, "80m"},
{7100000, "40m"},
{10120000, "30m"},
{14200000, "20m"},
{18100000, "17m"},
{21200000, "15m"},
{24940000, "12m"},
{28400000, "10m"},
{50150000, "6m"},
{70200000, "4m"},
{144300000, "2m"},
{223500000, "1.25m"},
{432000000, "70cm"},
{1296000000, "23cm"},
// The microwave bands that were missing. 3 cm is the one an operator
// reported: 10 GHz is worked and spotted, and resolved to nothing.
{2320000000, "13cm"},
{3400000000, "9cm"},
{5760000000, "6cm"},
{10368000000, "3cm"},
{24048000000, "1.25cm"},
{47088000000, "6mm"},
{76032000000, "4mm"},
{122250000000, "2.5mm"},
{134928000000, "2mm"},
{241920000000, "1mm"},
}
for _, c := range cases {
if got := cat.BandFromHz(c.hz); got != c.want {
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
}
if got := bandForHz(c.hz); got != c.want {
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
}
}
}
// A frequency in no amateur band must resolve to "" everywhere, not to the
// nearest band: guessing here would put a QSO in a band the operator never used.
func TestBandPlanRejectsOutOfBand(t *testing.T) {
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
if got := cat.BandFromHz(hz); got != "" {
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
}
if got := bandForHz(hz); got != "" {
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
}
}
}
+14
View File
@@ -1,4 +1,18 @@
[
{
"version": "0.21.8",
"date": "2026-07-28",
"en": [
"Microwave bands are recognised: 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm and above, plus 33cm and the 2190m/630m/560m low bands. A 10 GHz QSO used to come back with NO band at all — so it was logged without one, counted in no award slot, and exported without a BAND field. Band pickers, statistics and award band columns follow.",
"QRZ.com confirmation download works again: the date window used an option QRZ rejects, so every fetch failed. An automatic run now asks for everything modified since the last one — so an old QSO confirmed recently comes back — while a date you type fetches the QSOs made between that date and today.",
"Port fields can be cleared. Deleting the contents put the default straight back, so entering a different port meant overwriting it digit by digit. Fixed on the cluster editor, where it was reported, and in the eight other places with the same fault (CAT, amplifier, antenna, rotator, database, SMTP)."
],
"fr": [
"Les bandes hyperfréquences sont reconnues : 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm et au-delà, ainsi que 33cm et les bandes basses 2190m/630m/560m. Un QSO à 10 GHz revenait sans AUCUNE bande — donc enregistré sans bande, compté dans aucun diplôme, et exporté sans champ BAND. Les listes de bandes, les statistiques et les colonnes de diplômes suivent.",
"Le téléchargement des confirmations QRZ.com refonctionne : la fenêtre de date utilisait une option refusée par QRZ, donc chaque récupération échouait. Un téléchargement automatique demande désormais tout ce qui a été modifié depuis le précédent — un QSO ancien confirmé récemment revient donc — tandis qu'une date saisie récupère les QSO faits entre cette date et aujourd'hui.",
"Les champs de port peuvent être vidés. Effacer le contenu réinscrivait aussitôt la valeur par défaut, si bien qu'entrer un autre port obligeait à l'écraser chiffre par chiffre. Corrigé dans l'éditeur de cluster, où c'était signalé, et dans les huit autres endroits présentant le même défaut (CAT, amplificateur, antenne, rotator, base de données, SMTP)."
]
},
{
"version": "0.21.7",
"date": "2026-07-28",
+5 -1
View File
@@ -272,7 +272,11 @@ function bandForMHz(mhz: number): string {
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges — kept in step with BandFromHz on the Go side.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
+1 -1
View File
@@ -63,7 +63,7 @@ const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
+1 -1
View File
@@ -37,7 +37,7 @@ function pfxOf(call: string): string {
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
}
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
// label holds an i18n key (resolved with t() at render time).
const QSL_STATUSES = [
+61 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
@@ -2358,8 +2358,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.flex_port || 4992}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
<PortInput value={catCfg.flex_port || 4992}
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
</div>
<div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
@@ -2482,8 +2482,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
<PortInput value={catCfg.tci_port || 40001}
onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
</div>
<p className="col-span-2 text-xs text-muted-foreground">
{t('cat.tciHint')}
@@ -2654,10 +2654,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input
type="number" min={1} max={65535}
<PortInput
value={ultrabeam.port}
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
onChange={(n) => setUltrabeam((s) => ({ ...s, port: n }))} fallback={23}
className="font-mono"
/>
</div>
@@ -2943,8 +2942,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={amp.port}
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
<PortInput value={amp.port}
onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
</div>
</div>
)}
@@ -3114,10 +3113,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input
type="number" min={1} max={65535}
<PortInput
value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
className="font-mono"
/>
</div>
@@ -4678,7 +4676,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('db.host')}</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
<Label className="text-sm">{t('db.port')}</Label>
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
<PortInput className="h-8 w-28 font-mono" value={mysqlCfg.port} fallback={3306} onChange={(n) => setMysqlField({ port: n })} />
<Label className="text-sm">{t('db.database')}</Label>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">{t('db.user')}</Label>
@@ -5154,7 +5152,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
<Label className="text-sm">Port / encryption</Label>
<div className="flex gap-2 items-center">
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
<PortInput className="h-8 w-24 font-mono" value={emailCfg.smtp_port} fallback={587} onChange={(n) => setEmailField({ smtp_port: n })} />
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -5358,6 +5356,53 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
}
// PortInput — a TCP port field you can actually clear.
//
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
// the contents and parseInt gives NaN, so the default is written straight back:
// the digits reappear under the cursor and the only way to enter a different
// port is to overwrite character by character. Reported on the cluster editor;
// the same line existed in eight other places.
//
// The raw TEXT is the state here, and the parent is only told about a value that
// is actually a port. An empty box stays empty while you type; on blur it falls
// back to the last good value, so nothing is ever saved as 0 or NaN.
function PortInput({ value, onChange, fallback, className, min = 1, max = 65535 }: {
value: number; onChange: (n: number) => void; fallback: number; className?: string; min?: number; max?: number;
}) {
const [text, setText] = useState(value ? String(value) : '');
const typed = useRef(false);
// Only adopt the incoming value while the operator has NOT started typing —
// settings arrive from the backend after mount, and re-syncing mid-edit is
// precisely the behaviour being fixed.
useEffect(() => {
if (!typed.current) setText(value ? String(value) : '');
}, [value]);
return (
<Input
type="text"
inputMode="numeric"
className={className}
value={text}
onChange={(e) => {
typed.current = true;
const digits = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
setText(digits);
const n = parseInt(digits, 10);
if (n >= min && n <= max) onChange(n);
}}
onBlur={() => {
typed.current = false;
const n = parseInt(text, 10);
if (!(n >= min && n <= max)) {
setText(String(fallback));
onChange(fallback);
}
}}
/>
);
}
// ClusterServerEditor edits one row of cluster_servers. Init commands are
// free-form (one per line); the backend strips blanks and "//" comments.
interface ClusterEditorProps {
@@ -5389,7 +5434,7 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input type="number" min={1} max={65535} className="font-mono" value={s.port} onChange={(e) => update({ port: parseInt(e.target.value) || 7300 })} />
<PortInput className="font-mono" value={s.port} fallback={7300} onChange={(n) => update({ port: n })} />
</div>
<div className="space-y-1">
<Label>Login callsign (optional)</Label>
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.21.7';
export const APP_VERSION = '0.21.8';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+6 -1
View File
@@ -6,6 +6,7 @@
// QSO may yield several references (e.g. a Note holding "D74 D73").
//
// Examples:
//
// DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
@@ -1326,7 +1327,11 @@ func setToSorted(m map[string]struct{}) []string {
return out
}
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
// Microwave. A band missing from this order sorts to the end of an award's
// band columns rather than in frequency order — and 3 cm was missing.
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
func sortedBands(m map[string]int) []string {
idx := map[string]int{}
+32
View File
@@ -776,6 +776,14 @@ func stateUserEqual(a, b RigState) bool {
func BandFromHz(hz int64) string {
mhz := float64(hz) / 1_000_000
switch {
// LF / MF, below 160 m. An operator on 630 m had their band come back empty,
// which then cost them the band on the QSO and in every award count.
case mhz >= 0.1357 && mhz <= 0.1378:
return "2190m"
case mhz >= 0.472 && mhz <= 0.479:
return "630m"
case mhz >= 0.501 && mhz <= 0.504:
return "560m"
case mhz >= 1.8 && mhz <= 2.0:
return "160m"
case mhz >= 3.5 && mhz <= 4.0:
@@ -810,6 +818,30 @@ func BandFromHz(hz int64) string {
return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm"
// Microwave. The table used to stop at 23 cm, so 10 GHz — a band people
// actually work, and spot — resolved to an empty band: no band on the QSO, no
// band-slot, nothing in the awards. Ranges and names are the ADIF 3.1.7 ones,
// which is what an export has to carry.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
}
return ""
}
+22
View File
@@ -682,6 +682,28 @@ func bandFromHz(hz int64) string {
return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm"
// Microwave — a 10 GHz spot used to land with no band at all, so it could
// not be filtered, matched against the log, or shown on a band map.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
}
return ""
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.21.7"
appVersion = "0.21.8"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.