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.
This commit is contained in:
2026-07-27 20:50:44 +02:00
parent 2b5c195ab4
commit 816a727e88
16 changed files with 668 additions and 79 deletions
+69 -7
View File
@@ -1637,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)
}
@@ -1651,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 {
@@ -6541,7 +6566,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
return err
}
}
a.reloadCAT()
a.restartAsync("cat", a.reloadCAT)
return nil
}
@@ -11764,12 +11789,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
@@ -12105,7 +12152,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()
}
@@ -12835,7 +12885,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
return err
}
}
a.startUltrabeam()
a.restartAsync("antenna", a.startUltrabeam)
return nil
}
@@ -13408,7 +13458,7 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
return err
}
}
a.startAmps()
a.restartAsync("amp", a.startAmps)
return nil
}
@@ -13544,7 +13594,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
}
@@ -13989,6 +14039,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 {