Compare commits

...
8 Commits
Author SHA1 Message Date
rouggy 4dd15b09e9 chore: release v0.21.6 2026-07-27 21:26:31 +02:00
rouggy 4eec272c0b fix: bulk edit — name the confirmation fields properly, add the missing ones (#3)
"Upload" was wrong for half of them: a paper QSL is not uploaded. Every entry
now says whether it sets a STATUS or a DATE, and in which direction — "QRZ.com
sent status", "LoTW received date" — which is also how the ADIF fields read.

Added: the QRZ.com received status (the column existed, it was simply never
offered) and the ten confirmation DATES — QSL / LoTW / eQSL / QRZ.com in both
directions, Club Log and HRDLog outbound only, those two having no return
channel. Dates use the same picker as the QSL Info tab, with a Today (UTC)
button; clearing the field writes an empty value, which is how a wrong date is
removed from a batch.

Fields are grouped by CHANNEL now, each status followed by its date, in the same
order as the QSL Info tab — a longer list, but nothing to hunt for.

The repository whitelist is extended to match: without it the new columns would
have been refused at write time. The old ids (qrz_upload, clublog_upload,
hrdlog_upload) still resolve, in case anything still holds one.
2026-07-27 21:21:09 +02:00
rouggy 816a727e88 feat: stats timeline, band-map colours, frameless window, and four fixes
Statistics — the activity chart ignored the period selector: picking a year up
top left it showing the last 7 days, two controls contradicting each other. The
buttons were four fixed rolling windows anchored on the newest QSO, not
granularities. They now choose the BUCKET SIZE over the selected period, with an
Auto default and a step-up when a bucket would be too fine (the backend drops a
series past 750 buckets rather than ship 6000 unreadable bars). The hour-of-day
histogram covered a single day, too little to read anything from; it moves to
its own card with a weekday view, over the whole period.

Band map — the CW/data/phone sub-bands were shaded with the STATUS tokens, the
same amber that means "new band" on the pills drawn on top of them. A sub-band
is an identity, not a state: categorical hues now, validated in both themes
(violet failed on the dark steps — 1.9 ΔE from blue for a protan reader) and
added to the legend, since identity must never be colour-alone.

Frameless window — the OS title bar was a dead 32px band above a window that
already has its own. Minimise/maximise/close move into the app header, which
carries the drag region and double-click-to-maximise. The drag opt-out is by
ROLE in CSS, so a future button in that bar keeps working without anyone
remembering the rule.

Fixes:
- Saving settings froze the window while a device was slow: restarting a link
  waits for its poll goroutine, which can sit 45 s inside an uninterruptible COM
  Connect when another program holds the rig. The restart is now off the UI
  path; a slow one is logged.
- Multi-screen: a MAXIMISED window was never repositioned, so it came back on
  the primary screen every launch. The corner is now recorded even when
  maximised (it names the monitor) and re-applied while still hidden.
- The Callsign field is marked out at rest — operators were typing the call into
  Name, which sat beside it and looked identical.
- QSL dates get a real picker (native, for the OS calendar and locale order); a
  malformed old value stays in a text box rather than vanishing behind an empty
  one.

Also: a Support OpsLog entry in Help, and a WinKeyer protocol trace. The WK2
report (one element then a ten-second pause, sidetone that will not switch off)
is NOT fixed here: the WK1/WK2/WK3 command sets differ and a guessed fix would
break the operators it works for today. The trace logs every byte with the
command name and spells out the firmware family, which is what will settle it.
2026-07-27 20:50:44 +02:00
rouggy 2b5c195ab4 chore: release v0.21.5 2026-07-27 13:49:19 +02:00
rouggy 139b4675e3 fix: portable folder — stop storing absolute database paths
Moving the folder (C:\OpsLog → D:\OpsLog, or onto a stick) broke everything:
config.json and each profile's logbook path were absolute, so on the new
machine the pointer named a drive that no longer applied.

The logbook case was the dangerous one. db.Open CREATES what is missing, so if
the stale C: path happened to be creatable, the operator silently got a NEW
EMPTY logbook instead of an error — with their QSOs sitting untouched in the
folder they had just copied.

A path inside the application folder is now stored relative to it and resolved
against the current location at read time. A path OUTSIDE it (a synced folder,
a chosen drive) stays absolute and untouched — that is a deliberate choice; it
is only re-rooted when it has gone missing AND a file of the same name exists in
this install's data folder, i.e. the copied-folder case. With no such twin the
path is left alone so the failure is reported rather than papered over with an
unrelated database. Both rules are pinned by tests.
2026-07-27 13:48:11 +02:00
rouggy 2ad72b19fb feat: separate column layout for Recent QSOs in the Main tab
The Main-tab pane and the full Recent QSOs tab are the same component sharing
one storage key, but the pane is about half as wide — so it wants fewer and
narrower columns. Whichever was opened last rewrote the other's widths.

The pane now stores under "mainpane", which also scopes its award-column
visibility and widths. The full tab keeps the historical key, so its layout is
untouched; the pane starts from defaults once.
2026-07-27 11:58:24 +02:00
rouggy 678c8821c2 fix: column layouts would not stick — five separate faults
1. A language change rebuilt the column defs, which sets the anti-clobber
   guard, but the effect that clears it listened to only two of the rebuild's
   causes. Guard stuck on → every column change was silently discarded for the
   rest of the session. Now keyed on the rebuilt defs themselves, so any future
   cause is covered.
2. The cluster grid had no guard at all: the same rebuild wrote its defaults
   over the saved layout, in the cache AND the database. Unrecoverable.
3. Worked-before and cluster read their state before the active profile was
   known, so they looked up the unscoped cache key (always a miss) and then
   saved under the scoped one. They now wait for the scope and remount on a
   profile switch, like the main log.
4. GetUIPref returns an ERROR, not "", while the settings store is not yet
   scoped — deliberately distinct from "unset". The frontend read it as "no
   preference", rendered defaults, and the first column event overwrote the
   good copy. It now retries instead of giving up.
5. The QSL manager had no storageKey, so it shared the main log's layout and
   each rewrote the other.

Also: the DB write is debounced (a resize drag fired dozens of writes) with a
flush on close, and a rejected write is retried rather than dropped.
2026-07-27 11:56:43 +02:00
rouggy 5394b55bb7 feat: amplifier band-follow — answer the amp's frequency polls
On its CAT/AUX connector an ACOM is the MASTER: it polls a transceiver and
changes band from the reply, so nothing can be pushed to it. internal/catemu
answers those polls on a second serial port, in ACOM command set 5 (Kenwood /
Elecraft): FA;, FB;, IF; and ID;, and nothing else — a wrong-length reply is
worse than none, it desynchronises the amp's parser for the following poll.

Also offered for SPE. Some amps do not poll at all but read the radio↔PC CAT
line in parallel; those never hear a responder, so an optional unprompted send
(500/1000 ms) covers them.

The TX frequency is what is sent: in split the amp must be tuned where we
transmit. Frame lengths are pinned by tests — the failure they guard against is
silent and only shows up as an amp that mistunes.

Untested on hardware.
2026-07-27 11:56:30 +02:00
28 changed files with 1661 additions and 150 deletions
+254 -20
View File
@@ -30,6 +30,7 @@ import (
"hamlog/internal/backup"
"hamlog/internal/cabrillo"
"hamlog/internal/cat"
"hamlog/internal/catemu"
"hamlog/internal/clublog"
"hamlog/internal/cluster"
"hamlog/internal/contest"
@@ -917,6 +918,10 @@ func (a *App) startup(ctx context.Context) {
wruntime.EventsEmit(a.ctx, "cat:state", s)
}
a.emitRadioUDP(s)
// Feed the frequency to any amplifier we are pretending to be a radio
// for. Just two atomic stores per amp — the reply itself is built when
// the amp polls, so a fast-tuning VFO costs nothing here.
a.feedAmpBandFollow(s)
// Drive station relays by the current frequency/band (PstRotator-style
// automatic control). Cheap cached-flag check keeps this a no-op when the
// feature is off; when on, run off this callback so a slow relay board never
@@ -1512,6 +1517,74 @@ type dbPointer struct {
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
// ── Portable paths ─────────────────────────────────────────────────────
//
// OpsLog is meant to be carried on a stick or copied between machines: the exe,
// its data folder and the databases travel together. Storing "C:\OpsLog\data\
// logbook.db" broke that — dropped into D:\OpsLog on another PC, the pointer
// still named C:, and the app either lost the database or (worse) silently
// created a NEW empty one at a C: path that happened to be creatable.
//
// So a path INSIDE the application folder is stored relative to it, and any
// stored path is resolved against the CURRENT location at read time. Absolute
// paths outside the folder (a deliberate choice — a synced folder, another
// drive) keep working exactly as before: they are only re-rooted if they have
// gone missing AND the same file exists in this install's data folder.
// appDir is the folder the running exe lives in — the anchor for relative paths.
func appDir() string {
exe, err := os.Executable()
if err != nil {
return ""
}
return filepath.Dir(exe)
}
// portablePath prepares a path for STORAGE: relative to the app folder when it
// sits inside it, unchanged otherwise. Forward slashes so the value survives a
// round trip through a folder copied between machines.
func portablePath(p string) string {
p = strings.TrimSpace(p)
base := appDir()
if p == "" || base == "" || !filepath.IsAbs(p) {
return p
}
rel, err := filepath.Rel(base, p)
if err != nil || strings.HasPrefix(rel, "..") {
return p // outside the app folder — the user meant that exact place
}
return filepath.ToSlash(rel)
}
// resolvePath turns a stored path back into an absolute one for THIS install.
// dataDir is where the app's own data lives, used for the re-rooting rescue.
func resolvePath(dataDir, p string) string {
p = strings.TrimSpace(p)
if p == "" {
return ""
}
if !filepath.IsAbs(p) {
if base := appDir(); base != "" {
return filepath.Join(base, filepath.FromSlash(p))
}
return filepath.FromSlash(p)
}
if fileExists(p) {
return p
}
// An absolute path from another machine. Rescue it ONLY if a file of the same
// name is sitting in this install's data folder — that is the copied-folder
// case. Anything else is left alone so a genuinely-missing database is
// reported rather than silently replaced by an unrelated file.
if dataDir != "" {
if cand := filepath.Join(dataDir, filepath.Base(p)); fileExists(cand) {
applog.Printf("path: %q not found — using %q from this install (folder moved?)", p, cand)
return cand
}
}
return p
}
// ── Window geometry (window.json) ──────────────────────────────────────
//
// Remembered across restarts so the window reopens where and how you left it.
@@ -1564,6 +1637,14 @@ func (a *App) saveWindowState() {
if w > 0 && h > 0 {
ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y
}
} else if max {
// The POSITION is recorded even when maximised — it is what identifies the
// MONITOR. Without it a multi-screen operator who always runs maximised got
// no position at all, and Windows reopened OpsLog on the primary screen
// every time. The SIZE is deliberately not touched here: it would be the
// maximised size, which must not become the restored-down size.
x, y := wruntime.WindowGetPosition(a.ctx)
ws.X, ws.Y = x, y
}
writeWindowState(a.dataDir, ws)
}
@@ -1578,7 +1659,24 @@ func (a *App) restoreWindowPosition() {
return
}
ws, ok := readWindowState(a.dataDir)
if !ok || ws.Maximised {
if !ok {
return
}
// 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
}
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)
return
}
wruntime.WindowUnmaximise(a.ctx)
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
wruntime.WindowMaximise(a.ctx)
return
}
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
@@ -1631,12 +1729,16 @@ func readBootstrap(dataDir string) dbPointer {
return c
}
_ = json.Unmarshal(b, &c)
c.DBPath = strings.TrimSpace(c.DBPath)
// Stored relative when it lives inside the app folder, so the pointer follows
// the folder from C:OpsLog to D:OpsLog or to a stick.
c.DBPath = resolvePath(dataDir, c.DBPath)
c.DeletePending = resolvePath(dataDir, c.DeletePending)
return c
}
func writeBootstrap(dataDir string, c dbPointer) error {
c.DBPath = strings.TrimSpace(c.DBPath)
c.DBPath = portablePath(c.DBPath)
c.DeletePending = portablePath(c.DeletePending)
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
}
@@ -1774,6 +1876,15 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
if lp == "" {
return a.db, "sqlite", nil
}
// Resolve against THIS install before opening. Without it, a profile carried
// from C:\OpsLog to D:\OpsLog kept naming the C: path — and since db.Open
// CREATES what is missing, the operator silently got a brand-new empty
// logbook instead of an error. Their QSOs were still on disk, in the folder
// they had just copied.
if r := resolvePath(a.dataDir, lp); r != lp {
applog.Printf("logbook: profile path %q resolved to %q", lp, r)
lp = r
}
c, err := db.Open(lp)
if err != nil {
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
@@ -5451,16 +5562,33 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
// layer so the frontend works with stable ids, not raw column names.
var bulkFieldColumns = map[string]string{
// QSL / upload status
"lotw_sent": "lotw_sent",
"lotw_rcvd": "lotw_rcvd",
"eqsl_sent": "eqsl_sent",
"eqsl_rcvd": "eqsl_rcvd",
"qsl_sent": "qsl_sent",
"qsl_rcvd": "qsl_rcvd",
"qsl_via": "qsl_via",
"lotw_sent": "lotw_sent",
"lotw_rcvd": "lotw_rcvd",
"eqsl_sent": "eqsl_sent",
"eqsl_rcvd": "eqsl_rcvd",
"qsl_sent": "qsl_sent",
"qsl_rcvd": "qsl_rcvd",
"qsl_via": "qsl_via",
"qrz_sent": "qrzcom_qso_upload_status",
"qrz_rcvd": "qrzcom_qso_download_status",
"clublog_sent": "clublog_qso_upload_status",
"hrdlog_sent": "hrdlog_qso_upload_status",
// Old ids kept so anything holding one keeps working.
"qrz_upload": "qrzcom_qso_upload_status",
"clublog_upload": "clublog_qso_upload_status",
"hrdlog_upload": "hrdlog_qso_upload_status",
// Confirmation DATES. A status without its date is half the record: an
// operator correcting a batch after an import needs both.
"qsl_sent_date": "qsl_sent_date",
"qsl_rcvd_date": "qsl_rcvd_date",
"lotw_sent_date": "lotw_sent_date",
"lotw_rcvd_date": "lotw_rcvd_date",
"eqsl_sent_date": "eqsl_sent_date",
"eqsl_rcvd_date": "eqsl_rcvd_date",
"qrz_sent_date": "qrzcom_qso_upload_date",
"qrz_rcvd_date": "qrzcom_qso_download_date",
"clublog_sent_date": "clublog_qso_upload_date",
"hrdlog_sent_date": "hrdlog_qso_upload_date",
// My station / operator
"station_callsign": "station_callsign",
"operator": "operator",
@@ -6455,7 +6583,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
return err
}
}
a.reloadCAT()
a.restartAsync("cat", a.reloadCAT)
return nil
}
@@ -11678,12 +11806,34 @@ func (a *App) SwitchCATRig(n int) error {
if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
return err
}
a.reloadCAT()
a.restartAsync("cat", a.reloadCAT)
return nil
}
// reloadCAT (re)starts the CAT manager based on the current settings.
// Called at startup and after the user saves new CAT config.
// restartAsync runs a hardware (re)start off the caller's goroutine.
//
// Saving settings must never block the UI on a device. Restarting a link tears
// the old one down FIRST and waits for its poll goroutine to exit — and that
// goroutine can be tens of seconds deep inside a call that cannot be
// interrupted: OmniRig's COM Connect when another program (MSHV, a contest
// logger) is holding the rig takes ~45 s to give up. Save-and-close froze for
// exactly that long, because the Wails binding was waiting on it.
//
// The restart still takes as long; it just no longer holds the window. The
// duration is logged, since "the rig took 45 s to let go" is invisible
// otherwise and looks like OpsLog hanging.
func (a *App) restartAsync(name string, f func()) {
go func() {
t0 := time.Now()
f()
if d := time.Since(t0); d > 2*time.Second {
applog.Printf("%s: restart took %s (device slow to release/connect)", name, d.Round(time.Millisecond))
}
}()
}
func (a *App) reloadCAT() {
if a.cat == nil {
return
@@ -11955,6 +12105,10 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
if a.profiles == nil {
return profile.Profile{}, fmt.Errorf("profiles not initialized")
}
// Store a logbook that lives inside the app folder RELATIVE to it, so the
// profile keeps working when the folder is copied to another machine or
// another drive letter.
p.DB.Path = portablePath(p.DB.Path)
if err := a.profiles.Save(a.ctx, &p); err != nil {
return profile.Profile{}, err
}
@@ -12015,7 +12169,10 @@ func (a *App) reloadAfterProfileSwitch() {
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
a.reloadCAT()
// Off the caller's goroutine for the same reason as the settings save: this
// runs from ActivateProfile, a click, and a rig that is slow to release would
// otherwise freeze the switch.
a.restartAsync("cat", a.reloadCAT)
a.startQSORecorderIfEnabled()
}
@@ -12745,7 +12902,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
return err
}
}
a.startUltrabeam()
a.restartAsync("antenna", a.startUltrabeam)
return nil
}
@@ -13318,7 +13475,7 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
return err
}
}
a.startAmps()
a.restartAsync("amp", a.startAmps)
return nil
}
@@ -13343,13 +13500,26 @@ type AmpConfig struct {
Port int `json:"port"`
ComPort string `json:"com_port"`
Baud int `json:"baud"`
// Band-follow: an ACOM is the MASTER on its CAT/AUX connector — it polls a
// transceiver and changes band from the reply, so following OpsLog means
// answering those polls on a SECOND serial port (independent of the one used
// above for metering; both run at once). See internal/catemu.
FreqOut bool `json:"freq_out"`
FreqComPort string `json:"freq_com_port"`
FreqBaud int `json:"freq_baud"`
// FreqBroadcastMs > 0 also SENDS the frequency unprompted at that interval,
// for an amplifier that listens to a transceiver's CAT stream instead of
// polling it. 0 = answer polls only.
FreqBroadcastMs int `json:"freq_broadcast_ms"`
}
type ampInst struct {
cfg AmpConfig
pgxl *powergenius.Client
spe *spe.Client
acom *acom.Client
cfg AmpConfig
pgxl *powergenius.Client
spe *spe.Client
acom *acom.Client
catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM)
}
func (i *ampInst) stopAll() {
@@ -13362,6 +13532,9 @@ func (i *ampInst) stopAll() {
if i.acom != nil {
i.acom.Stop()
}
if i.catemu != nil {
i.catemu.Stop()
}
}
// ampTypeLabel is the default display name for an amp type.
@@ -13438,7 +13611,7 @@ func (a *App) SaveAmplifiers(list []AmpConfig) error {
if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
return err
}
a.startAmps()
a.restartAsync("amp", a.startAmps)
return nil
}
@@ -13491,12 +13664,61 @@ func (a *App) startAmps() {
a.spe = inst.spe
}
}
// Band-follow on a SECOND serial port, for any amp that takes its band from
// a transceiver CAT link (ACOM and SPE both do). Independent of the control
// transport above, so metering and band-follow run at the same time.
if c.FreqOut && strings.TrimSpace(c.FreqComPort) != "" {
inst.catemu = catemu.New(catemu.Config{
ComPort: c.FreqComPort, Baud: c.FreqBaud,
BroadcastMs: c.FreqBroadcastMs,
}, applog.Printf)
if st := a.cat.State(); st.Connected {
inst.catemu.SetFrequency(st.FreqHz)
inst.catemu.SetMode(st.Mode)
}
inst.catemu.Start()
applog.Printf("amp %s: band-follow on %s at %d baud (Kenwood format, broadcast=%dms)",
c.Name, c.FreqComPort, c.FreqBaud, c.FreqBroadcastMs)
}
a.ampsMu.Lock()
a.ampInsts[c.ID] = inst
a.ampsMu.Unlock()
}
}
// feedAmpBandFollow pushes the rig's frequency/mode to every amplifier we are
// emulating a transceiver for. Called on each CAT state change.
//
// The TX frequency is what the amp must follow: in split it has to be tuned
// for where we transmit, not where we listen.
func (a *App) feedAmpBandFollow(s cat.RigState) {
if !s.Connected || s.FreqHz <= 0 {
return
}
a.ampsMu.Lock()
defer a.ampsMu.Unlock()
for _, inst := range a.ampInsts {
if inst.catemu != nil {
inst.catemu.SetFrequency(s.FreqHz)
inst.catemu.SetMode(s.Mode)
}
}
}
// GetAmpBandFollowStatus returns the band-follow link state per amplifier id,
// so the settings panel can show whether the amp is actually polling us.
func (a *App) GetAmpBandFollowStatus() map[string]catemu.Status {
out := map[string]catemu.Status{}
a.ampsMu.Lock()
defer a.ampsMu.Unlock()
for id, inst := range a.ampInsts {
if inst.catemu != nil {
out[id] = inst.catemu.GetStatus()
}
}
return out
}
// AmpStatus is one amp's live state for the UI poll — exactly one of the
// per-family payloads is set, per the amp's type.
type AmpStatus struct {
@@ -13834,6 +14056,18 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
return nil
}
// SetWinkeyerTrace turns the byte-level WinKeyer protocol trace on or off.
//
// The K1EL command set differs across WK1 / WK2 / WK3 and a mismatch is silent:
// the keyer accepts a byte meant for another command and keys something odd
// (one element then a long pause, a sidetone that will not switch off). Reading
// the actual wire is the only way to tell which command the firmware misread,
// and it beats guessing from a description — a guessed fix breaks the operators
// for whom it currently works.
func (a *App) SetWinkeyerTrace(on bool) {
winkeyer.SetTrace(on)
}
// WinkeyerConnect opens the serial link using the saved config.
func (a *App) WinkeyerConnect() error {
if a.winkeyer == nil {
+46
View File
@@ -1,4 +1,50 @@
[
{
"version": "0.21.6",
"date": "2026-07-27",
"en": [
"Statistics: the activity chart now follows the period you selected, and Day / Week / Month / Year choose the bucket size (QSOs per week over the whole period, for instance). It used to show fixed windows — the last 7 days, the last 30 — regardless of the period.",
"Statistics: new \"When you are on the air\" card — by hour and by day of week, over the selected period.",
"Band map: the CW / data / phone sub-bands are no longer shaded in the status colours — the SSB portion was amber, the same amber that means \"new band\" on the spots above it. They now use distinct, colour-blind-checked hues and appear in the legend.",
"Help menu: a Support OpsLog entry, which opens the donation page in your browser.",
"The Callsign field is now visually marked out, so it is no longer mistaken for Name next to it.",
"Multi-screen: OpsLog reopens on the screen it was closed on, including when it was maximised — it used to come back on the primary screen every time.",
"The Windows title bar is gone: minimise, maximise and close now sit in the OpsLog bar, which you drag to move the window (double-click to maximise). That is 32 pixels of screen back.",
"Saving the settings no longer freezes OpsLog while a device is slow to answer. Choosing OmniRig while another program held the rig locked the window for about 45 seconds — the time OmniRig takes to give up. The link is now re-established in the background, and a slow restart is written to the diagnostic log.",
"Edit QSO → QSL Info: the sent and received dates now have a calendar picker, plus a button for today's date (UTC).",
"CW keyer: the diagnostic log now names the keyer generation (WK1 / WK2 / WK3), and Settings → CW keyer can log every byte exchanged with it — for reporting a keyer that behaves oddly.",
"Bulk edit: the confirmation fields are renamed \"sent/received status\" instead of \"upload\", the missing QRZ.com received status is added, and every channel now offers its sent and received DATE with a calendar."
],
"fr": [
"Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.",
"Statistiques : nouvelle carte « Quand vous trafiquez » — par heure et par jour de semaine, sur la période choisie.",
"Carte de bande : les portions CW / numérique / phonie ne sont plus teintées avec les couleurs de statut — la partie SSB était ambre, l'ambre qui signifie « nouvelle bande » sur les spots posés dessus. Elles utilisent maintenant des teintes distinctes, vérifiées pour le daltonisme, et figurent dans la légende.",
"Menu Aide : une entrée « Soutenir OpsLog », qui ouvre la page de don dans votre navigateur.",
"Le champ Indicatif est désormais mis en évidence, pour ne plus être confondu avec le champ Nom voisin.",
"Multi-écrans : OpsLog se rouvre sur l'écran où il a été fermé, y compris s'il était maximisé — il revenait jusqu'ici sur l'écran principal à chaque fois.",
"La barre de titre Windows a disparu : réduire, agrandir et fermer sont désormais dans la barre OpsLog, qu'on saisit pour déplacer la fenêtre (double-clic pour agrandir). Autant de pixels d'écran repris.",
"Enregistrer les réglages ne fige plus OpsLog quand un appareil tarde à répondre. Choisir OmniRig alors qu'un autre logiciel tenait la radio bloquait la fenêtre une quarantaine de secondes — le temps qu'OmniRig renonce. La liaison est désormais rétablie en arrière-plan, et un redémarrage lent est noté dans le journal de diagnostic.",
"Édition QSO → Infos QSL : les dates d'envoi et de réception disposent d'un calendrier, et d'un bouton pour la date du jour (UTC).",
"Keyer CW : le journal de diagnostic nomme désormais la génération du keyer (WK1 / WK2 / WK3), et Réglages → Keyer CW permet de journaliser chaque octet échangé avec lui — pour signaler un keyer au comportement anormal.",
"Édition en lot : les champs de confirmation s'appellent désormais « envoi/réception (statut) » au lieu d'« upload », le statut de réception QRZ.com manquant a été ajouté, et chaque canal propose ses DATES d'envoi et de réception avec un calendrier."
]
},
{
"version": "0.21.5",
"date": "2026-07-27",
"en": [
"Column layouts (widths, order, hidden columns) now stick — five faults were undoing them.",
"Recent QSOs keeps a separate column layout in the Main tab and in its own tab — the pane is half as wide there.",
"ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier).",
"Portable folder: moving OpsLog to another drive or PC (C:OpsLog → D:OpsLog) no longer loses the databases — paths inside the folder are now stored relative to it."
],
"fr": [
"Les dispositions de colonnes (largeurs, ordre, colonnes masquées) tiennent enfin — cinq défauts les défaisaient.",
"Les QSO récents gardent une disposition de colonnes distincte dans l'onglet Principal et dans leur propre onglet — le volet y est deux fois moins large.",
"Les amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur).",
"Dossier portable : déplacer OpsLog sur un autre disque ou un autre PC (C:OpsLog → D:OpsLog) ne fait plus perdre les bases — les chemins internes au dossier sont désormais enregistrés relativement à celui-ci."
]
},
{
"version": "0.21.4",
"date": "2026-07-26",
+92 -9
View File
@@ -52,7 +52,7 @@ import {
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
import { EventsOn, BrowserOpenURL } 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 { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -95,7 +95,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref';
import { setGridPrefsProfile } from '@/lib/gridPrefs';
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
import { Button } from '@/components/ui/button';
@@ -331,6 +331,54 @@ function computePrefix(call: string): string {
return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
}
// WindowControls — minimise / maximise / close, since the window is frameless.
//
// Deliberately shaped like the Windows ones (flat, wide, close goes red on
// hover) rather than styled like the app's own buttons: these are OS controls,
// and an operator must recognise them without reading them. Close runs the
// normal shutdown path — the same one the OS button used — so the backup and
// on-close uploads still happen.
function WindowControls() {
const [max, setMax] = useState(false);
useEffect(() => {
WindowIsMaximised().then(setMax).catch(() => {});
}, []);
const btn = 'inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors';
return (
<div className="flex items-center gap-0.5 -mr-2 ml-1" data-no-drag>
<button type="button" className={btn} onClick={() => WindowMinimise()} title="Minimise" aria-label="Minimise">
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden><path d="M0 5h10" stroke="currentColor" strokeWidth="1.2" /></svg>
</button>
<button type="button" className={btn}
// Flip the icon straight away, then confirm from the runtime: reading it
// back immediately returns the state BEFORE the toggle has been applied.
onClick={() => {
WindowToggleMaximise();
setMax((v) => !v);
setTimeout(() => { WindowIsMaximised().then(setMax).catch(() => {}); }, 150);
}}
title={max ? 'Restore' : 'Maximise'} aria-label={max ? 'Restore' : 'Maximise'}>
{max ? (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="0.6" y="2.6" width="6.8" height="6.8" /><path d="M2.6 2.6V0.6h6.8v6.8H7.4" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="0.6" y="0.6" width="8.8" height="8.8" />
</svg>
)}
</button>
<button type="button"
className="inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-danger hover:text-danger-foreground transition-colors"
onClick={() => Quit()} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden stroke="currentColor" strokeWidth="1.2">
<path d="M0.5 0.5l9 9M9.5 0.5l-9 9" />
</svg>
</button>
</div>
);
}
export default function App() {
const { t, lang } = useI18n();
// === Lists from settings (fallback for first paint) ===
@@ -2356,6 +2404,14 @@ export default function App() {
}).catch(() => {});
}, []);
// A column resized in the last seconds before quitting must still reach the
// database: the DB write is debounced, so flush it on the way out.
useEffect(() => {
const flush = () => flushGridPrefs();
window.addEventListener('beforeunload', flush);
return () => { window.removeEventListener('beforeunload', flush); flush(); };
}, []);
// Every setting is per-profile, so when the active profile changes the whole
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
// side reloads its managers; this keeps the React state in sync.
@@ -3284,6 +3340,8 @@ export default function App() {
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
] : []),
{ type: 'separator' },
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
{ type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
@@ -3317,6 +3375,9 @@ export default function App() {
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
case 'help.sendlog': sendLogToDeveloper(); break;
// Opens in the system browser, NOT the app WebView: a payment page must show
// the address bar and padlock the donor knows how to check.
case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
}
}
@@ -3448,7 +3509,7 @@ export default function App() {
const callsignBlock = (
<div className="flex flex-col w-44" data-esm="call">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
{t('field.callsign')}
<span className="text-primary font-semibold">{t('field.callsign')}</span>
{lookupBusy && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
<Loader2 className="size-2.5 animate-spin" />
@@ -3502,8 +3563,14 @@ export default function App() {
)}
<Input
ref={callsignRef}
// The call field is marked out at REST, not only on focus: operators were
// typing the callsign into Name, which sits right beside it and looked
// exactly the same. A primary-tinted border plus an inset left bar make
// it the obvious one without adding a colour the theme doesn't own. The
// contest-dupe state still overrides it — that one is a warning.
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground')}
'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
value={callsign}
onChange={(e) => onCallsignInput(e.target.value)}
onBlur={() => {
@@ -4147,7 +4214,7 @@ export default function App() {
</div>
<div className="flex-1 min-h-0 flex">
<div className="flex-1 min-w-0 flex flex-col min-h-0">
<ClusterGrid rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
<ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
</div>
{clusterShowFilters && renderClusterFilters()}
</div>
@@ -4156,7 +4223,7 @@ export default function App() {
case 'worked':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WorkedBeforeGrid wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
@@ -4192,6 +4259,11 @@ export default function App() {
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<RecentQSOsGrid
key={`rqg-${activeProfileId ?? 'x'}`}
// Its OWN layout, separate from the full-width Recent QSOs tab.
// This pane is roughly half as wide, so it wants fewer columns and
// narrower ones; sharing one key meant whichever was opened last
// rewrote the other's widths.
storageKey="mainpane"
rows={qsosWithAwards as any}
myGrid={station.my_grid}
total={total}
@@ -4222,7 +4294,7 @@ export default function App() {
{compact ? (
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
// so the single-row entry strip fits in a ~140px tall window.
<header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
<header className="app-dragregion flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
<div className="flex items-center gap-1.5">
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
<span className="font-bold text-xs tracking-tight">OpsLog</span>
@@ -4243,7 +4315,15 @@ export default function App() {
</Button>
</header>
) : (
<header className="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
<header
className="app-dragregion grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm"
// Double-click anywhere on the bar toggles maximise, as a title bar does.
// Frameless windows get none of that for free.
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest('button,a,input,select,nav,[role="button"]')) return;
WindowToggleMaximise();
}}
>
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
<div className="flex items-baseline gap-1.5">
@@ -4611,6 +4691,8 @@ export default function App() {
>
<Minimize2 className="size-3.5" />
</Button>
{/* Window controls — the OS title bar is gone, so they live here. */}
<WindowControls />
</div>
</header>
)}
@@ -5485,6 +5567,7 @@ export default function App() {
}
return (
<ClusterGrid
key={`clg2-${activeProfileId ?? 'x'}`}
rows={rendered as any}
spotStatus={spotStatus}
onSpotClick={handleSpotClick}
@@ -5569,7 +5652,7 @@ export default function App() {
</TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
+46 -16
View File
@@ -64,25 +64,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
'70cm': [430000, 440000],
};
// Sub-band shading: CW / digital / phone.
//
// These are IDENTITIES (which mode the segment is for), not states, so they take
// categorical hues — never the status tokens. They used to use success / info /
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
// is "new band" in this very component's legend, so the whole SSB portion read as
// a giant "new band" wash. Status colours are reserved for status.
//
// All three are drawn from the COOL end of the categorical order and laid down at
// low opacity, so the band plan stays background context while the warm spot pills
// keep the foreground to themselves.
// Checked with the palette validator in BOTH themes. Violet was the first pick
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
// protan reader there, i.e. the same colour. Magenta clears every check on both
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
const SEG_CW = 'var(--chart-7)'; // magenta
const SEG_DIGI = 'var(--chart-1)'; // blue
const SEG_PHONE = 'var(--chart-2)'; // aqua
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
// are read on top of it.
const SEG_OPACITY = 0.13;
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
'60m': [[5350, 5450, 'fill-warning-muted']],
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
'60m': [[5350, 5450, SEG_PHONE]],
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
};
// Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, label }: { cls: string; label: string }) {
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
return (
<span className="inline-flex items-center gap-1">
<span className={cn('size-2 rounded-full', cls)} />
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
{label}
</span>
);
@@ -447,10 +469,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
height={totalH}
preserveAspectRatio="none"
>
{segments.map(([s, e, cls], i) => {
{segments.map(([s, e, colour], i) => {
const y1 = freqToY(Math.min(e, hi));
const y2 = freqToY(Math.max(s, lo));
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
return (
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
fill={colour} fillOpacity={SEG_OPACITY} />
);
})}
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
</svg>
@@ -559,7 +584,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
<span className="mx-0.5 opacity-40">|</span>
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
</div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
{t('bmp.footerHint')}
+50 -12
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
type FieldKind = 'status' | 'text' | 'freq';
type FieldKind = 'status' | 'text' | 'freq' | 'date';
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
@@ -21,16 +21,31 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
// label holds an i18n key (resolved with t() at render time).
const FIELDS: FieldDef[] = [
// QSL / upload status
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
// One channel at a time, each with its status and its date, in the same order
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
// QSL is not uploaded — so every entry now reads "<channel> sent/received
// status" or "… date", which is also what the ADIF field is called.
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
// My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
@@ -117,7 +132,7 @@ type Props = {
// so they become eligible for upload.
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
const { t } = useI18n();
const [field, setField] = useState('hrdlog_upload');
const [field, setField] = useState('qsl_sent');
const [statusValue, setStatusValue] = useState('R');
const [textValue, setTextValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -181,6 +196,29 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
</SelectContent>
</Select>
) : def.kind === 'date' ? (
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
// writes an empty value — which is how a wrong date is removed
// from a batch, the same meaning as leaving a text field blank.
<div className="flex gap-1">
<Input
type="date"
className="h-8 text-xs font-mono"
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
/>
<Button
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
title={t('bulk.todayUtc')}
onClick={() => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
// UTC: the whole log is UTC, and near midnight the local day
// is the wrong one.
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
}}
>{t('bulk.today')}</Button>
</div>
) : (
<Input
className="h-8 text-xs"
+33 -12
View File
@@ -12,7 +12,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
type TFn = (key: string, vars?: Record<string, string | number>) => string;
@@ -371,10 +371,28 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), [COL_CATALOG]);
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
// matching column events. Without this guard those events were persisted, so a
// single language change overwrote the saved cluster layout — in the cache AND
// in the database, with nothing to restore from.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
restoringRef.current = true;
return COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
});
}, [COL_CATALOG]);
// Re-apply the saved state after every rebuild, then re-enable saving.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(tm);
}, [columnDefs]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
@@ -394,17 +412,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
gridRef.current?.api?.refreshCells({ force: true });
}, [spotStatus]);
function onGridReady(e: GridReadyEvent) {
// Restore AFTER the profile scope is known — this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint.
async function onGridReady(e: GridReadyEvent) {
await whenGridPrefsReady();
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
loadRemote(COL_STATE_KEY).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
});
const remote = await loadRemote(COL_STATE_KEY);
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
+5 -1
View File
@@ -6,7 +6,10 @@ import {
import { cn } from '@/lib/utils';
export type MenuItem =
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
// accent highlights an item that is an OFFER rather than a command (Donate).
// Generic on purpose: a hard-coded label test in the renderer would break the
// moment the menu is translated.
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
| { type: 'separator' };
export interface Menu {
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
<DropdownMenuItem
key={i}
disabled={item.disabled}
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
onSelect={() => onAction(item.action)}
>
<span>{item.label}</span>
@@ -427,6 +427,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
// drives which QSOs the apply-form below updates; a search selects all.
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
// Its OWN column layout. Without a storageKey this fell back to
// the main log's key, so every resize or hidden column here
// silently rewrote the Recent QSOs layout, and vice versa.
storageKey="qslmgr.paper"
rows={paperRows as any}
total={paperRows.length}
selectAllSignal={paperSelAllSig}
@@ -532,6 +536,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : (
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
storageKey="qslmgr.upload"
rows={rows as any}
total={rows.length}
selectAllSignal={uploadSelAllSig}
+53 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector';
@@ -148,6 +148,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
);
}
// AdifDateInput — a real date picker over an ADIF date.
//
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
// are converted at the edge and the log keeps its ADIF form. The native control
// is used on purpose: it brings the OS calendar, the locale's day/month order
// and keyboard entry for free, which a hand-rolled popover would have to
// reimplement and get wrong.
//
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
// a plain text box instead, so it stays visible and correctable rather than
// silently disappearing behind an empty picker.
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
const raw = (value ?? '').trim();
const valid = /^\d{8}$/.test(raw);
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
const today = () => {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
// UTC: every date in the log is UTC, and near midnight the local day is the
// wrong one.
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
};
if (raw !== '' && !valid) {
return (
<div className="flex gap-1">
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
</div>
);
}
return (
<div className="flex gap-1">
<Input
type="date"
value={iso}
disabled={disabled}
onChange={(e) => {
const v = e.target.value; // "" when cleared
onChange(v ? v.replace(/-/g, '') : '');
}}
className="font-mono"
/>
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
onClick={today} title="Today (UTC)">
<CalendarDays className="size-3.5" />
</Button>
</div>
);
}
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n();
return (
@@ -580,8 +630,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
: <Input disabled value="—" />}
</div>
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
{def.via && (
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
)}
+7 -1
View File
@@ -521,7 +521,13 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols, awardShown]);
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
// causes here (awardCols/awardShown) missed the others — switching the UI
// language rebuilds the memo through `t`, which set restoringRef and left it
// set, so every column change for the rest of the session was silently
// discarded and the layout reverted on reload. Any future dependency of the
// memo is now covered for free.
}, [columnDefs]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
+75 -1
View File
@@ -35,6 +35,7 @@ import {
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults,
SetWinkeyerTrace,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
@@ -627,7 +628,10 @@ function ADIFMonitorPanel() {
}
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
// Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
// frequency polls, pretending to be a Kenwood-format transceiver.
freq_out?: boolean; freq_com_port?: string; freq_baud?: number; freq_broadcast_ms?: number };
// RelayAutoPanel configures automatic control of the Station Control relay boards
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
@@ -1090,6 +1094,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
});
const [wkPorts, setWkPorts] = useState<string[]>([]);
// Session-only: the byte trace is a diagnostic, never a saved preference.
const [wkTrace, setWkTrace] = useState(false);
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
// ── Audio (DVK + QSO recorder) ──
@@ -2811,6 +2817,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
});
const addAmp = () => setAmps((l) => [...l, {
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
freq_out: false, freq_com_port: '', freq_baud: 9600, freq_broadcast_ms: 0,
}]);
return (
<>
@@ -2927,6 +2934,62 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
)}
{/* Band-follow, for any amp that takes its band from a transceiver
CAT link (ACOM and SPE both do) never PowerGenius, which is
driven over its network protocol. A SECOND serial port,
separate from the metering one above. */}
{!isPGXL && (
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!amp.freq_out}
onCheckedChange={(c) => patchAmp(i, { freq_out: !!c })}
/>
{t('amp.freqOut')}
</label>
{amp.freq_out && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>{t('amp.freqPort')}</Label>
<div className="flex items-center gap-2">
<Select value={amp.freq_com_port || '_'} onValueChange={(v) => patchAmp(i, { freq_com_port: v === '_' ? '' : v })}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Input type="number" min={1200} value={amp.freq_baud ?? 9600}
onChange={(e) => patchAmp(i, { freq_baud: parseInt(e.target.value) || 9600 })} className="font-mono" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>{t('amp.freqBroadcast')}</Label>
<Select value={String(amp.freq_broadcast_ms ?? 0)} onValueChange={(v) => patchAmp(i, { freq_broadcast_ms: parseInt(v) })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0">{t('amp.freqPollOnly')}</SelectItem>
<SelectItem value="500">{t('amp.freqEvery', { ms: 500 })}</SelectItem>
<SelectItem value="1000">{t('amp.freqEvery', { ms: 1000 })}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-[10px] text-muted-foreground">{t('amp.freqHint')}</p>
</>
)}
</div>
)}
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
{!isPGXL && !isACOM && (
<p className="text-[10px] text-muted-foreground">
@@ -3303,6 +3366,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
</label>
</div>
{/* Protocol trace for reporting a keyer that misbehaves. Session
only, deliberately not persisted: it is a diagnostic, and a log
full of hex bytes helps nobody who forgot it was on. */}
<div className="border-t border-border/60 pt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wkTrace} onCheckedChange={(c) => { setWkTrace(!!c); SetWinkeyerTrace(!!c); }} />
{t('wk.trace')}
</label>
<p className="text-[10px] text-muted-foreground mt-1">{t('wk.traceHint')}</p>
</div>
</>
)}
+63 -6
View File
@@ -33,6 +33,10 @@ type Stats = {
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
// Chronological series across the SELECTED period, one per bucket size; empty
// when that bucket would be too fine for the period (see the Go side).
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
// Period / contest metrics.
window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: number;
@@ -509,19 +513,44 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
// own state, module-scoped so a parent re-render doesn't reset the choice. The
// timeline is the chronological month series; day/week/month/year are the cyclical
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
// panel — not a window of its own. It used to be four fixed rolling windows
// anchored on the newest QSO, which ignored the period filter entirely: picking
// a year up top left this chart showing the last 7 days.
const ACT_GRAN = [
{ key: 'timeline', tkey: 'stats.granTimeline' },
{ key: 'day', tkey: 'stats.granDay' },
{ key: 'week', tkey: 'stats.granWeek' },
{ key: 'month', tkey: 'stats.granMonth' },
{ key: 'year', tkey: 'stats.granYear' },
] as const;
type Gran = typeof ACT_GRAN[number]['key'];
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<string>('timeline');
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
const [g, setG] = useState<Gran | 'auto'>('auto');
const seriesFor = (k: Gran): Bucket[] => (
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
);
// Auto picks the finest bucket that stays readable: the backend leaves a
// series empty when it would be too fine for the period, so "the first
// non-empty one" is exactly the right rule and needs no thresholds here.
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
// A bucket the user forces but that is too fine for the period falls back to
// the next coarser one rather than showing an empty chart.
let shown: Gran = g === 'auto' ? auto : g;
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
const series = seriesFor(shown);
const steppedUp = g !== 'auto' && shown !== g;
return (
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<div className="mb-2 flex flex-wrap gap-1">
<div className="mb-2 flex flex-wrap gap-1 items-center">
<button type="button" onClick={() => setG('auto')}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t('stats.granAuto')}
</button>
{ACT_GRAN.map((o) => (
<button key={o.key} type="button" onClick={() => setG(o.key)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
@@ -529,14 +558,39 @@ function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: an
{t(o.tkey)}
</button>
))}
{steppedUp && (
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
)}
</div>
{g === 'timeline'
? <AreaTrend data={stats.by_month} empty={empty} />
{/* Many buckets read better as a filled trend than as a forest of bars. */}
{series.length > 60
? <AreaTrend data={series} empty={empty} />
: <VBars data={series} empty={empty} showValues height={160} />}
</Card>
);
}
// RhythmCard — WHEN the operator is on the air over the selected period, which is
// a different question from "how much lately" and so gets its own card. The hour
// histogram used to cover a single day, too little to read anything from.
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<'hour' | 'weekday'>('hour');
return (
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
<div className="mb-2 flex flex-wrap gap-1">
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
<button key={k} type="button" onClick={() => setG(k)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t(tk)}
</button>
))}
</div>
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
</Card>
);
}
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
@@ -603,6 +657,8 @@ export function StatsPanel() {
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
rate: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats);
@@ -818,6 +874,7 @@ export function StatsPanel() {
</Card>
<ActivityCard stats={stats} t={t} empty={empty} />
<RhythmCard stats={stats} t={t} empty={empty} />
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
worked what, and a cap would quietly delete the 9th operator. Scrolls
+14 -9
View File
@@ -15,7 +15,7 @@ import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView, QSOForm } from '@/types';
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -113,15 +113,18 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
sortable: true, resizable: true, filter: true, suppressMovable: false,
}), []);
function onGridReady(e: GridReadyEvent) {
// Restore AFTER the profile scope is known: this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint, so it
// used to miss the cache every time and then save under the scoped key.
async function onGridReady(e: GridReadyEvent) {
await whenGridPrefsReady();
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
loadRemote(COL_STATE_KEY).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
});
const remote = await loadRemote(COL_STATE_KEY);
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore events fired by a column rebuild
@@ -137,7 +140,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
// columnDefs itself, not the reasons it was rebuilt — see the same note in
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
}, [columnDefs]);
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
+72 -8
View File
@@ -19,6 +19,26 @@ let lsScope = '';
// changes so each profile keeps its own column layout / widths.
export function setGridPrefsProfile(id: number | string | null | undefined): void {
lsScope = id == null || id === '' ? '' : `p${id}.`;
markReady();
}
// The active profile is only known after an async call, but the grids mount and
// read their state on the first paint. A grid that read before the scope was set
// looked up the UNSCOPED cache key (always a miss) and then saved under the
// scoped one — so its layout could never be restored, only rewritten. Grids now
// wait for this instead of racing it.
//
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
// still come up with whatever the unscoped cache holds rather than hang with no
// columns restored.
let markReady: () => void = () => {};
const gridPrefsReady = new Promise<void>((resolve) => {
markReady = resolve;
setTimeout(resolve, 5000);
});
export function whenGridPrefsReady(): Promise<void> {
return gridPrefsReady;
}
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
@@ -40,22 +60,66 @@ export function loadLocal(key: string): any[] | null {
}
// loadRemote pulls the portable copy from the DB (null if none / unset).
export async function loadRemote(key: string): Promise<any[] | null> {
try {
const v = await GetUIPref(key);
const parsed = v ? JSON.parse(v) : null;
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
//
// GetUIPref returns an ERROR — not "" — while the settings store is still
// coming up and not yet scoped to the active profile. Treating that as "no
// preference" was the silent data-loss path: the grid rendered defaults and the
// first column event then wrote those defaults over the good saved copy. So
// retry for a few seconds instead, and only give up on a real absence.
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
for (let i = 0; i < attempts; i++) {
try {
const v = await GetUIPref(key);
const parsed = v ? JSON.parse(v) : null;
return Array.isArray(parsed) ? parsed : null;
} catch {
// Not ready yet (or a parse failure on a corrupt value — one more read
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
await new Promise((r) => setTimeout(r, 300));
}
}
return null;
}
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
// the cache key is profile-scoped; the DB key is scoped by the backend.
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
// frame, so dragging one border used to fire dozens of settings writes; the
// cache write stays synchronous so nothing is lost if the window closes.
const pendingDB = new Map<string, string>();
const dbTimers = new Map<string, number>();
export function saveState(key: string, state: any[]) {
const json = JSON.stringify(state);
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
pendingDB.set(key, json);
const prev = dbTimers.get(key);
if (prev) clearTimeout(prev);
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
}
function flushOne(key: string) {
const json = pendingDB.get(key);
dbTimers.delete(key);
if (json == null) return;
pendingDB.delete(key);
SetUIPref(key, json).catch(() => {
// The store may not be scoped yet at startup. Keep the value and try once
// more shortly — dropping it here is how a fresh profile ended up with no
// portable copy at all.
pendingDB.set(key, json);
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
});
}
// flushGridPrefs writes any debounced state immediately. Call it when the app is
// about to close so a resize made in the last moments still reaches the DB.
export function flushGridPrefs() {
for (const key of Array.from(pendingDB.keys())) {
const t = dbTimers.get(key);
if (t) clearTimeout(t);
flushOne(key);
}
}
// seedLocal writes a value into the cache without touching the DB (used after
File diff suppressed because one or more lines are too long
+34
View File
@@ -652,3 +652,37 @@
border: 2px solid var(--background);
}
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
/* Frameless window: drag region
The OS title bar is gone (main.go, Frameless), so the app header IS the title
bar and must be draggable. Everything interactive inside it opts OUT: a drag
region swallows clicks, and without this the menus and toolbar buttons would
move the window instead of doing their job. Opting out by ROLE rather than
listing each child keeps a future button working without anyone remembering
this rule. */
.app-dragregion {
--wails-draggable: drag;
}
.app-dragregion button,
.app-dragregion a,
.app-dragregion input,
.app-dragregion select,
.app-dragregion textarea,
.app-dragregion nav,
.app-dragregion [role='button'],
.app-dragregion [role='menu'],
.app-dragregion [data-no-drag] {
--wails-draggable: no-drag;
}
/* Native date inputs: the WebView draws its calendar glyph in near-black, which
disappears on the dark themes. Invert it there the control itself is worth
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
help. */
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.6);
opacity: 0.75;
}
+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.4';
export const APP_VERSION = '0.21.6';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+5
View File
@@ -6,6 +6,7 @@ import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models';
import {acom} from '../models';
import {catemu} from '../models';
import {antgenius} from '../models';
import {award} from '../models';
import {awardref} from '../models';
@@ -344,6 +345,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>;
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
@@ -904,6 +907,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
+8
View File
@@ -638,6 +638,10 @@ export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo']();
}
export function GetAmpBandFollowStatus() {
return window['go']['main']['App']['GetAmpBandFollowStatus']();
}
export function GetAmpStatuses() {
return window['go']['main']['App']['GetAmpStatuses']();
}
@@ -1758,6 +1762,10 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function SetWinkeyerTrace(arg1) {
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
}
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
+20
View File
@@ -1442,6 +1442,10 @@ export namespace main {
port: number;
com_port: string;
baud: number;
freq_out: boolean;
freq_com_port: string;
freq_baud: number;
freq_broadcast_ms: number;
static createFrom(source: any = {}) {
return new AmpConfig(source);
@@ -1458,6 +1462,10 @@ export namespace main {
this.port = source["port"];
this.com_port = source["com_port"];
this.baud = source["baud"];
this.freq_out = source["freq_out"];
this.freq_com_port = source["freq_com_port"];
this.freq_baud = source["freq_baud"];
this.freq_broadcast_ms = source["freq_broadcast_ms"];
}
}
export class AmpStatus {
@@ -4196,6 +4204,12 @@ export namespace qso {
by_day7: Bucket[];
by_day30: Bucket[];
by_month12: Bucket[];
by_day_win: Bucket[];
by_week_win: Bucket[];
by_month_win: Bucket[];
by_year_win: Bucket[];
by_hour_of_day: Bucket[];
by_weekday: Bucket[];
window_start: string;
window_end: string;
window_hours: number;
@@ -4240,6 +4254,12 @@ export namespace qso {
this.by_day7 = this.convertValues(source["by_day7"], Bucket);
this.by_day30 = this.convertValues(source["by_day30"], Bucket);
this.by_month12 = this.convertValues(source["by_month12"], Bucket);
this.by_day_win = this.convertValues(source["by_day_win"], Bucket);
this.by_week_win = this.convertValues(source["by_week_win"], Bucket);
this.by_month_win = this.convertValues(source["by_month_win"], Bucket);
this.by_year_win = this.convertValues(source["by_year_win"], Bucket);
this.by_hour_of_day = this.convertValues(source["by_hour_of_day"], Bucket);
this.by_weekday = this.convertValues(source["by_weekday"], Bucket);
this.window_start = source["window_start"];
this.window_end = source["window_end"];
this.window_hours = source["window_hours"];
+376
View File
@@ -0,0 +1,376 @@
// Package catemu emulates a transceiver on a serial port so a device that
// POLLS a radio for its frequency can follow OpsLog instead.
//
// This exists for the ACOM amplifiers: on their CAT/AUX connector the amp is
// the master — it polls the transceiver every few hundred milliseconds and
// changes band only when it gets a valid reply. There is no way to push a
// frequency to it, so following OpsLog means answering its polls.
//
// The dialect is ACOM "command set 5" (Kenwood / Elecraft RS-232, also what
// Flex and SunSDR users select): plain ASCII commands terminated by ';'. It is
// the simplest of the five sets by a wide margin, which is why the SDC utility
// uses it to steer an ACOM with no physical radio attached.
//
// Only the handful of commands an amp actually asks for are implemented:
//
// FA; → FA00014025000; TX frequency, 11 digits, Hz
// FB; → same (sub VFO — amps poll it on some firmware)
// IF; → the 38-character TS-2000 status frame
// ID; → ID019; (TS-2000 — a known model keeps the amp from timing out)
//
// Anything else is ignored rather than answered: a wrong-length reply is worse
// than none, because it desynchronises the amp's parser for the next poll.
package catemu
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"go.bug.st/serial"
)
// Config is the serial port the amplifier's CAT/AUX cable is wired to. This is
// a SECOND port, independent of the one used for the amp's own remote/metering
// protocol — both run at the same time on an ACOM.
type Config struct {
ComPort string
Baud int
// BroadcastMs > 0 also sends the frequency UNPROMPTED every that many
// milliseconds. Some amplifiers do not poll at all: they sit in parallel on
// the radio↔PC CAT line and read whatever goes past. Such an amp would never
// hear us, since answering polls means speaking only when spoken to.
// 0 = answer polls only.
BroadcastMs int
}
// Status is what the settings panel shows about the link.
type Status struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
Port string `json:"port"`
Polls int64 `json:"polls"` // replies sent since start
LastCmd string `json:"last_cmd"` // last command received, e.g. "FA;"
LastAt string `json:"last_at"` // RFC3339 of the last poll, "" if none
FreqHz int64 `json:"freq_hz"` // what we are currently answering
Error string `json:"error"` // last open/IO failure
}
// Server answers a polling amplifier on one serial port.
type Server struct {
cfg Config
mu sync.Mutex
port serial.Port
status Status
freqHz atomic.Int64
mode atomic.Value // string, ADIF-ish ("CW", "USB"…)
stop chan struct{}
done chan struct{}
logf func(string, ...any)
}
// New builds a server. Nothing is opened until Start.
func New(cfg Config, logf func(string, ...any)) *Server {
if cfg.Baud <= 0 {
cfg.Baud = 9600
}
s := &Server{cfg: cfg, logf: logf}
s.mode.Store("")
s.status.Port = cfg.ComPort
return s
}
func (s *Server) log(format string, args ...any) {
if s.logf != nil {
s.logf(format, args...)
}
}
// SetFrequency updates the frequency reported to the amplifier. Called from the
// CAT state callback; safe from any goroutine and never blocks — the serve loop
// reads the value when a poll arrives, so a fast-tuning VFO costs nothing.
func (s *Server) SetFrequency(hz int64) { s.freqHz.Store(hz) }
// SetMode updates the mode digit in the IF frame. Optional: the amp only cares
// about the frequency, but a coherent frame avoids odd firmware behaviour.
func (s *Server) SetMode(mode string) { s.mode.Store(strings.ToUpper(strings.TrimSpace(mode))) }
// Start opens the port and serves polls until Stop. It returns immediately;
// a port that is missing or busy is retried every 5 s, because the amplifier is
// often powered on after the software.
func (s *Server) Start() {
s.stop = make(chan struct{})
s.done = make(chan struct{})
go s.run()
}
// Stop closes the port and waits for the loop to end.
func (s *Server) Stop() {
if s.stop == nil {
return
}
close(s.stop)
s.mu.Lock()
if s.port != nil {
_ = s.port.Close()
s.port = nil
}
s.mu.Unlock()
<-s.done
s.stop = nil
}
// GetStatus returns a snapshot for the UI.
func (s *Server) GetStatus() Status {
s.mu.Lock()
defer s.mu.Unlock()
st := s.status
st.Enabled = true
st.FreqHz = s.freqHz.Load()
return st
}
func (s *Server) setErr(msg string) {
s.mu.Lock()
s.status.Error = msg
s.status.Connected = false
s.mu.Unlock()
}
func (s *Server) run() {
defer close(s.done)
for {
select {
case <-s.stop:
return
default:
}
if err := s.open(); err != nil {
s.setErr(err.Error())
s.log("catemu: %s open failed: %v (retry in 5s)", s.cfg.ComPort, err)
select {
case <-s.stop:
return
case <-time.After(5 * time.Second):
}
continue
}
s.serve()
}
}
func (s *Server) open() error {
if strings.TrimSpace(s.cfg.ComPort) == "" {
return fmt.Errorf("no COM port configured")
}
p, err := serial.Open(s.cfg.ComPort, &serial.Mode{
BaudRate: s.cfg.Baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
})
if err != nil {
return err
}
// A short read timeout keeps the loop responsive to Stop while idle: the amp
// may poll only every few hundred ms, and a blocking read would hold the
// port open past shutdown.
_ = p.SetReadTimeout(200 * time.Millisecond)
s.mu.Lock()
s.port = p
s.status.Connected = true
s.status.Error = ""
s.mu.Unlock()
s.log("catemu: serving Kenwood-format polls on %s at %d baud", s.cfg.ComPort, s.cfg.Baud)
return nil
}
// broadcast sends an unsolicited FA frame at the configured interval, for an
// amplifier that listens to the CAT line rather than polling it. It stops when
// the port is closed or Stop is called.
func (s *Server) broadcast(stopServe <-chan struct{}) {
if s.cfg.BroadcastMs <= 0 {
return
}
// Below ~100 ms this is pure noise on the wire; the band only ever changes
// at human speed.
every := time.Duration(s.cfg.BroadcastMs) * time.Millisecond
if every < 100*time.Millisecond {
every = 100 * time.Millisecond
}
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-s.stop:
return
case <-stopServe:
return
case <-t.C:
hz := s.freqHz.Load()
if hz <= 0 {
continue // nothing known yet — say nothing rather than "0 Hz"
}
s.mu.Lock()
p := s.port
s.mu.Unlock()
if p == nil {
return
}
if _, err := p.Write([]byte(fmt.Sprintf("FA%011d;", hz))); err != nil {
s.setErr(err.Error())
return
}
}
}
}
// serve reads commands until the port fails or Stop is called.
func (s *Server) serve() {
// The broadcaster shares this port and must die with it, or it would write
// into a closed handle after a reopen.
stopServe := make(chan struct{})
defer close(stopServe)
go s.broadcast(stopServe)
buf := make([]byte, 64)
var acc []byte
for {
select {
case <-s.stop:
return
default:
}
s.mu.Lock()
p := s.port
s.mu.Unlock()
if p == nil {
return
}
n, err := p.Read(buf)
if err != nil {
s.setErr(err.Error())
s.log("catemu: %s read failed: %v — reopening", s.cfg.ComPort, err)
s.mu.Lock()
if s.port != nil {
_ = s.port.Close()
s.port = nil
}
s.mu.Unlock()
return
}
if n == 0 {
continue
}
acc = append(acc, buf[:n]...)
// Commands are ';'-terminated; handle every complete one in the buffer.
for {
i := indexByte(acc, ';')
if i < 0 {
break
}
cmd := strings.ToUpper(strings.TrimSpace(string(acc[:i])))
acc = acc[i+1:]
s.handle(p, cmd)
}
// A runaway buffer means we are seeing something that is not this
// protocol (wrong baud, or the amp's other port); drop it rather than
// grow without bound.
if len(acc) > 512 {
acc = acc[:0]
}
}
}
func indexByte(b []byte, c byte) int {
for i := range b {
if b[i] == c {
return i
}
}
return -1
}
// handle answers one command. cmd has no trailing ';'.
func (s *Server) handle(p serial.Port, cmd string) {
hz := s.freqHz.Load()
var reply string
switch {
case cmd == "FA" || cmd == "FB":
reply = fmt.Sprintf("%s%011d;", cmd, hz)
case cmd == "IF":
reply = s.ifFrame(hz)
case cmd == "ID":
reply = "ID019;" // TS-2000
default:
// Unknown or a SET command (FA00014025000;) — silently ignored: an amp
// never sets our frequency, and answering the wrong length would break
// its parser for the following poll.
return
}
if _, err := p.Write([]byte(reply)); err != nil {
s.setErr(err.Error())
return
}
s.mu.Lock()
s.status.Polls++
s.status.LastCmd = cmd + ";"
s.status.LastAt = time.Now().Format(time.RFC3339)
s.mu.Unlock()
}
// modeDigit maps our mode name to the Kenwood mode digit used in IF.
func (s *Server) modeDigit() byte {
m, _ := s.mode.Load().(string)
switch {
case strings.HasPrefix(m, "CW"):
return '3'
case strings.HasPrefix(m, "LSB"):
return '1'
case strings.HasPrefix(m, "USB"), strings.HasPrefix(m, "SSB"):
return '2'
case strings.HasPrefix(m, "FM"):
return '4'
case strings.HasPrefix(m, "AM"):
return '5'
case m == "RTTY", strings.HasPrefix(m, "FSK"):
return '6'
case m == "":
return '2'
default:
// Data modes (FT8, PSK…) ride on SSB as far as an amplifier cares.
return '2'
}
}
// ifFrame builds the 38-character TS-2000 IF status frame:
//
// IF | freq(11) | step(4) | RIT(6) | RIT/XIT/…(3) | memch(2) | rx/tx | mode |
// FR | scan | split | tone | tone#(2) | shift | ;
//
// Only the frequency and mode carry meaning here; the rest is a valid, inert
// state (no RIT, receiving, simplex) so the amp's parser is satisfied.
func (s *Server) ifFrame(hz int64) string {
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%01d%01d%01d%01d%02d%01d;",
hz, // P1 frequency, Hz
0, // P2 frequency step
0, // P3 RIT/XIT offset, signed 5 digits
0, // P4-P6 RIT off, XIT off, channel-bank
0, // P7 memory channel
0, // P8 0 = RX
s.modeDigit(), // P9 mode
0, // P10 VFO A
0, // P11 scan off
0, // P12 split off
0, // P13 tone off
0, // P14 tone number
0, // P15 shift
)
}
+45
View File
@@ -0,0 +1,45 @@
package catemu
import "testing"
// The reply LENGTHS are the contract: an amplifier parses these frames by
// fixed offsets, so a frame one character short desynchronises its parser for
// every following poll. Pin them.
func TestReplyShapes(t *testing.T) {
s := New(Config{ComPort: "COM99", Baud: 9600}, nil)
s.SetFrequency(14025000)
s.SetMode("CW")
if got := s.ifFrame(14025000); len(got) != 38 {
t.Errorf("IF frame is %d chars, want 38: %q", len(got), got)
}
// TS-2000 IF: "IF" then the 11-digit frequency in Hz.
if got := s.ifFrame(14025000); got[:13] != "IF00014025000" {
t.Errorf("IF frame frequency field = %q", got[:13])
}
if got := s.ifFrame(14025000); got[len(got)-1] != ';' {
t.Errorf("IF frame not terminated by ';': %q", got)
}
// Mode digit sits at P9, right after freq(11)+step(4)+rit(6)+3+2+1 — index 29 in the frame.
if got := s.ifFrame(14025000)[29]; got != '3' {
t.Errorf("CW mode digit = %q, want '3'", got)
}
s.SetMode("FT8") // data rides on SSB as far as an amp cares
if got := s.ifFrame(14074000)[29]; got != '2' {
t.Errorf("FT8 mode digit = %q, want '2'", got)
}
}
func TestModeDigit(t *testing.T) {
cases := map[string]byte{
"CW": '3', "CW-R": '3', "LSB": '1', "USB": '2', "SSB": '2',
"FM": '4', "AM": '5', "RTTY": '6', "": '2', "FT8": '2',
}
for mode, want := range cases {
s := New(Config{}, nil)
s.SetMode(mode)
if got := s.modeDigit(); got != want {
t.Errorf("mode %q → %q, want %q", mode, got, want)
}
}
}
+17 -5
View File
@@ -536,11 +536,23 @@ type UploadRow struct {
// uploadStatusCols whitelists the per-service sent-status columns the QSL
// Manager may filter on (guards the dynamic column name in the query).
var uploadStatusCols = map[string]bool{
"lotw_sent": true,
"eqsl_sent": true,
"qrzcom_qso_upload_status": true,
"clublog_qso_upload_status": true,
"hrdlog_qso_upload_status": true,
"lotw_sent": true,
"eqsl_sent": true,
"qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true,
"hrdlog_qso_upload_status": true,
// Confirmation dates (ADIF YYYYMMDD strings, so plain TEXT like the rest).
"qsl_sent_date": true,
"qsl_rcvd_date": true,
"lotw_sent_date": true,
"lotw_rcvd_date": true,
"eqsl_sent_date": true,
"eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true,
"qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true,
"hrdlog_qso_upload_date": true,
}
// ListForUpload returns QSOs whose per-service sent-status column equals
+145 -32
View File
@@ -156,28 +156,49 @@ type Stats struct {
ByMode []Bucket `json:"by_mode"`
ByBand []Bucket `json:"by_band"`
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
ByOperator []Bucket `json:"by_operator"`
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
ByContinent []Bucket `json:"by_continent"`
TopEntities []Bucket `json:"top_entities"`
ByYear []Bucket `json:"by_year"` // chronological
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
ByOperator []Bucket `json:"by_operator"`
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
ByContinent []Bucket `json:"by_continent"`
TopEntities []Bucket `json:"top_entities"`
ByYear []Bucket `json:"by_year"` // chronological
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
// the "activity over time" chart's day/week/month/year granularity selector.
// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
// Chronological activity ACROSS THE SELECTED PERIOD, one series per bucket
// size. These are what the activity chart's day/week/month/year selector
// drives: the rolling windows above ignored the period filter, so choosing a
// year in the panel left the chart showing the last 7 days regardless — two
// controls contradicting each other on screen.
//
// A series is EMPTY when it would exceed maxActivityBuckets (per-day over a
// seventeen-year log is ~6000 bars: unreadable, and pointless to transport).
// The UI treats an empty series as "too fine for this period" and steps up.
ByDayWin []Bucket `json:"by_day_win"`
ByWeekWin []Bucket `json:"by_week_win"`
ByMonthWin []Bucket `json:"by_month_win"`
ByYearWin []Bucket `json:"by_year_win"`
// Rhythm: WHEN the operator is on the air, over the selected period. Distinct
// question from the above ("how much, lately"), so it gets its own card. The
// hour histogram used to cover a single day, which is too little to read
// anything from.
ByHourOfDay []Bucket `json:"by_hour_of_day"` // 00h..23h UTC
ByWeekday []Bucket `json:"by_weekday"` // Mon..Sun
// ── Period / contest metrics ──
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
// nothing, but across a contest weekend it is the score. The window is the
// requested [from,to] when given, else the span of the log.
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
WindowHours float64 `json:"window_hours"`
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
WindowHours float64 `json:"window_hours"`
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
@@ -297,28 +318,28 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
defer rows.Close()
var (
calls = map[string]struct{}{}
entities = map[int]struct{}{}
modeC = map[string]int{}
bandC = map[string]int{}
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
opC = map[string]int{}
stationC = map[string]int{}
contC = map[string]int{}
entityC = map[string]int{}
yearC = map[string]int{}
calls = map[string]struct{}{}
entities = map[int]struct{}{}
modeC = map[string]int{}
bandC = map[string]int{}
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
opC = map[string]int{}
stationC = map[string]int{}
contC = map[string]int{}
entityC = map[string]int{}
yearC = map[string]int{}
monthC = map[string]int{}
times []entry // every dated QSO (+ its operator), for the rate / gap maths
times []entry // every dated QSO (+ its operator), for the rate / gap maths
first, last time.Time
)
for rows.Next() {
var (
call, band, mode, cont, country sql.NullString
oper, station sql.NullString
lotw, eqsl, paper sql.NullString
dxcc sql.NullInt64
dateStr, contestID2 sql.NullString
call, band, mode, cont, country sql.NullString
oper, station sql.NullString
lotw, eqsl, paper sql.NullString
dxcc sql.NullInt64
dateStr, contestID2 sql.NullString
)
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
@@ -492,11 +513,95 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
ref = time.Now()
}
s.recentSeries(times, ref)
s.windowSeries(times, first, last, from, to)
s.ensureNonNil()
return s, nil
}
// maxActivityBuckets caps a chronological series. Beyond this the chart is a
// grey smear and the payload grows for nothing; the series is dropped and the UI
// steps up to a coarser bucket.
const maxActivityBuckets = 750
// windowSeries builds the chronological activity series ACROSS THE SELECTED
// PERIOD, one per bucket size, plus the hour-of-day / weekday rhythm.
//
// The period is [from,to] when the caller filtered, else the span of the log —
// the same window the period metrics use, so every card on the panel answers for
// the same slice of time.
func (s *Stats) windowSeries(times []entry, first, last, from, to time.Time) {
start, end := from, to
if start.IsZero() {
start = first
}
if end.IsZero() {
end = last
}
if start.IsZero() || end.IsZero() || end.Before(start) {
return
}
start, end = start.UTC(), end.UTC()
day := map[string]int{}
week := map[string]int{}
month := map[string]int{}
year := map[string]int{}
hour := make([]int, 24)
weekday := make([]int, 7)
for _, e := range times {
t := e.t.UTC()
if t.Before(start) || t.After(end) {
continue
}
day[t.Format("2006-01-02")]++
// ISO week, so a week is Monday→Sunday everywhere and the key sorts.
wy, wn := t.ISOWeek()
week[fmt.Sprintf("%04d-W%02d", wy, wn)]++
month[t.Format("2006-01")]++
year[t.Format("2006")]++
hour[t.Hour()]++
weekday[(int(t.Weekday())+6)%7]++ // Go weeks start on Sunday; shift to Monday
}
// Every bucket in the range is emitted, including the empty ones: a gap in
// the log is real information and must show as zero, not be closed up.
d0 := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
dEnd := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
if n := int(dEnd.Sub(d0).Hours()/24) + 1; n >= 1 && n <= maxActivityBuckets {
for d := d0; !d.After(dEnd); d = d.AddDate(0, 0, 1) {
s.ByDayWin = append(s.ByDayWin, Bucket{Key: d.Format("2006-01-02"), Count: day[d.Format("2006-01-02")]})
}
}
// Weeks: start on the Monday of the first week and step by 7 days.
w0 := d0.AddDate(0, 0, -((int(d0.Weekday()) + 6) % 7))
if n := int(dEnd.Sub(w0).Hours()/24)/7 + 1; n >= 1 && n <= maxActivityBuckets {
for w := w0; !w.After(dEnd); w = w.AddDate(0, 0, 7) {
wy, wn := w.ISOWeek()
key := fmt.Sprintf("%04d-W%02d", wy, wn)
s.ByWeekWin = append(s.ByWeekWin, Bucket{Key: key, Count: week[key]})
}
}
m0 := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
mEnd := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
if n := (mEnd.Year()-m0.Year())*12 + int(mEnd.Month()) - int(m0.Month()) + 1; n >= 1 && n <= maxActivityBuckets {
for m := m0; !m.After(mEnd); m = m.AddDate(0, 1, 0) {
s.ByMonthWin = append(s.ByMonthWin, Bucket{Key: m.Format("2006-01"), Count: month[m.Format("2006-01")]})
}
}
for y := start.Year(); y <= end.Year(); y++ {
k := fmt.Sprintf("%04d", y)
s.ByYearWin = append(s.ByYearWin, Bucket{Key: k, Count: year[k]})
}
for h := 0; h < 24; h++ {
s.ByHourOfDay = append(s.ByHourOfDay, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
}
for i, name := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
s.ByWeekday = append(s.ByWeekday, Bucket{Key: name, Count: weekday[i]})
}
}
// recentSeries builds the rolling chronological activity windows for the chart's
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
// filtered, else now). Real dates in order — today and the days before it — so the
@@ -569,6 +674,14 @@ func (s *Stats) ensureNonNil() {
if s.ByMonth == nil {
s.ByMonth = []Bucket{}
}
// A window series stays EMPTY on purpose when the bucket would be too fine
// for the period — but it must be [] and not null, so the UI can tell
// "too fine" from "not computed".
for _, p := range []*[]Bucket{&s.ByDayWin, &s.ByWeekWin, &s.ByMonthWin, &s.ByYearWin, &s.ByHourOfDay, &s.ByWeekday} {
if *p == nil {
*p = []Bucket{}
}
}
if s.Rate == nil {
s.Rate = []Bucket{}
}
@@ -588,9 +701,10 @@ func (s *Stats) ensureNonNil() {
// log itself.
//
// Two rates are reported on purpose, because a single one always flatters:
// AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
// AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// - AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
// - AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// how fast you go when you ARE at the radio.
//
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
if len(times) == 0 {
@@ -790,7 +904,6 @@ func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
return out
}
// fillMonths emits EVERY month between the first and last QSO — zeros included —
// so the trend line's x-axis is real time rather than "months that happen to have
// data". A quiet decade must read as a decade at zero, not vanish.
+105 -1
View File
@@ -16,6 +16,7 @@ import (
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
"go.bug.st/serial"
@@ -179,7 +180,7 @@ func (m *Manager) Connect(cfg Config) error {
stop, done := m.stopRead, m.doneRead
m.mu.Unlock()
applog.Printf("winkeyer: connected on %s (firmware byte %d)", cfg.Port, ver)
applog.Printf("winkeyer: connected on %s — %s", cfg.Port, firmwareFamily(ver))
go m.readLoop(p, stop, done)
if err := m.applyConfig(cfg); err != nil {
@@ -398,10 +399,112 @@ func (m *Manager) write(b []byte) error {
if p == nil {
return fmt.Errorf("winkeyer: not connected")
}
traceTX(b)
_, err := p.Write(b)
return err
}
// ── Protocol trace ─────────────────────────────────────────────────────
//
// The WinKeyer command set differs between WK1, WK2 and WK3, and the failures
// it produces are silent: the keyer accepts a byte meant for another command
// and keys something odd. Guessing at that from a description ("it sends one
// element then pauses ten seconds") is how a fix for one firmware breaks the
// others, so the wire is made visible instead.
//
// Off by default — 1200 baud is slow but a long message would still fill the
// log. Enabled per session from the CW settings panel.
var traceOn atomic.Bool
// SetTrace turns the byte-level protocol trace on or off.
func SetTrace(on bool) {
traceOn.Store(on)
if on {
applog.Printf("winkeyer: protocol trace ON — every byte to and from the keyer is logged")
}
}
func hexBytes(b []byte) string {
var sb strings.Builder
for i, c := range b {
if i > 0 {
sb.WriteByte(' ')
}
fmt.Fprintf(&sb, "%02X", c)
if c >= 0x20 && c < 0x7F {
fmt.Fprintf(&sb, "(%c)", c)
}
}
return sb.String()
}
func traceTX(b []byte) {
if !traceOn.Load() || len(b) == 0 {
return
}
applog.Printf("winkeyer: TX %s %s", hexBytes(b), cmdName(b))
}
func traceRX(b []byte) {
if !traceOn.Load() || len(b) == 0 {
return
}
applog.Printf("winkeyer: RX %s", hexBytes(b))
}
// cmdName names the command a TX frame carries, so the trace can be read
// without the datasheet open beside it.
func cmdName(b []byte) string {
if len(b) == 0 || b[0] >= 0x20 {
return "(text)"
}
switch b[0] {
case 0x00:
return "admin"
case 0x01:
return "sidetone"
case 0x02:
return "set wpm"
case 0x03:
return "set weight"
case 0x04:
return "ptt lead/tail"
case 0x05:
return "setup speed pot"
case 0x06:
return "pause"
case 0x0A:
return "clear buffer"
case 0x0D:
return "farnsworth wpm"
case 0x0E:
return "set mode register"
case 0x11:
return "0x11 (WK2: key compensation / WK3: see datasheet)"
case 0x15:
return "request status"
default:
return fmt.Sprintf("cmd 0x%02X", b[0])
}
}
// firmwareFamily names the keyer generation from the byte returned by Host
// Open. The version is what decides which command set applies, so it is spelled
// out in the log rather than left as a bare number.
func firmwareFamily(ver int) string {
switch {
case ver == 0:
return "no reply — the keyer did not answer Host Open"
case ver < 20:
return fmt.Sprintf("WK1 (v%d)", ver)
case ver < 30:
return fmt.Sprintf("WK2 (v%d)", ver)
default:
return fmt.Sprintf("WK3 (v%d)", ver)
}
}
// Disconnect sends Host Close and releases the port.
func (m *Manager) Disconnect() {
m.mu.Lock()
@@ -473,6 +576,7 @@ func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
}
return
}
traceRX(buf[:n])
for i := 0; i < n; i++ {
b := buf[i]
switch {
+6
View File
@@ -112,6 +112,12 @@ func main() {
MinWidth: 1100,
MinHeight: 700,
WindowStartState: startState,
// No OS title bar: it was a dead 32-pixel band above a window that already
// has its own title strip. The app header takes over — it carries the drag
// region, the double-click-to-maximise, and the minimise/maximise/close
// buttons. Windows still draws the resize borders and honours Aero Snap,
// which is why the frame is dropped rather than the whole chrome.
Frameless: true,
// Start hidden and reveal only once the saved position has been applied and
// the DOM has painted (OnDomReady → domReady) — so the window appears
// already at its final size and position, with no post-launch jump.
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// The portable-folder contract: a database that lives inside the application
// folder must be stored relative to it, so copying C:\OpsLog to D:\OpsLog (or to
// a USB stick) keeps working. A path the operator deliberately put elsewhere
// must be left exactly as it is.
func TestPortablePath(t *testing.T) {
base := appDir()
if base == "" {
t.Skip("no executable dir available")
}
inside := filepath.Join(base, "data", "logbook.db")
if got := portablePath(inside); got != "data/logbook.db" {
t.Errorf("inside the app folder: got %q, want %q", got, "data/logbook.db")
}
// Outside: an absolute path on another drive / a synced folder is a
// deliberate choice and must survive untouched.
outside := filepath.FromSlash("Z:/Sync/ham/logbook.db")
if got := portablePath(outside); got != outside {
t.Errorf("outside the app folder: got %q, want it unchanged", got)
}
if got := portablePath(""); got != "" {
t.Errorf("empty path: got %q", got)
}
}
func TestResolvePath(t *testing.T) {
base := appDir()
if base == "" {
t.Skip("no executable dir available")
}
// A relative path is anchored to the CURRENT install, whatever drive it is on.
want := filepath.Join(base, "data", "logbook.db")
if got := resolvePath("", "data/logbook.db"); got != want {
t.Errorf("relative: got %q, want %q", got, want)
}
// An absolute path that still exists is honoured as-is.
dir := t.TempDir()
real := filepath.Join(dir, "logbook.db")
if err := os.WriteFile(real, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if got := resolvePath(dir, real); got != real {
t.Errorf("existing absolute: got %q, want %q", got, real)
}
// The rescue: a path from ANOTHER machine, with the same file present in this
// install's data folder — the copied-folder case.
stale := filepath.FromSlash("C:/OldPC/OpsLog/data/logbook.db")
if got := resolvePath(dir, stale); got != real {
t.Errorf("stale absolute with a local twin: got %q, want %q", got, real)
}
// No local twin: leave it alone so the failure is REPORTED rather than
// silently replaced by an unrelated database.
missing := filepath.FromSlash("C:/OldPC/OpsLog/data/other.db")
if got := resolvePath(dir, missing); got != missing {
t.Errorf("stale absolute with no twin: got %q, want it unchanged", got)
}
}
+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.4"
appVersion = "0.21.6"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.