Compare commits

...
2 Commits
Author SHA1 Message Date
rouggy e6889caae9 chore: release v0.26.1 2026-08-21 01:43:14 +02:00
rouggy e3b7a35e2c fix: stop losing decodes, hanging on exit, and wedging the rig link
Three faults an operator's log finally made visible, plus the interface
work that came out of the same session.

Reliability:

- UDP events were dropped on backpressure without a word. A period hands
  over twenty-odd decodes at once, and one slow write to the radio was
  enough to fill the queue — so a decode simply never appeared, and the
  only detector was the operator comparing the panel with JTDX. The drop
  is now counted and logged, panadapter spots went to their own goroutine
  so the radio can no longer hold the decode stream up, and the queue is
  deep enough for a full period.

- The CAT manager waited for its poll loop with a bare <-done. A loop
  wedged in a serial read then blocked every later restart inside Start,
  before it could even try to connect: the rig stayed dead, no line was
  written anywhere, and only killing the process recovered it. The wait
  is bounded at ten seconds and says what it abandoned and why the next
  connect may fail.

- Shutdown had no logging at all, so a hang left nothing to go on and a
  process the operator had to kill — which then blocked the restart after
  an update. Every step is logged, and a watchdog forces the exit if one
  of them never returns.

Auto-call:

- A QSO in progress is now held by OpsLog itself rather than inferred
  from the sender's Status. The moment WSJT-X/JTDX dropped the DX call or
  the Enable-Tx flag between overs, the exchange looked finished and the
  next CQ was answered, interleaving two and then three QSOs on one
  slice. Released on log, on halt, on taking over, and by a watchdog.

Cluster console:

- Replies to a command were buried under the spot flood; a Replies
  toggle hides the DX spots, which the list above already shows.
- Twelve named command buttons beside the input, configured in
  Settings -> Cluster; a button with no command is not drawn.
- Following the tail is now an explicit switch, and sending a command
  re-arms it. It used to measure "am I at the bottom" AFTER committing
  the new lines, so a ten-line reply looked like the operator had
  scrolled up and was never followed — the one case it exists for.

Awards:

- An award can name NO field. The matching controls disappear with it
  and only hand-assigned references count, which is the only thing that
  can feed a reference like WWBOTA. A test pins that nothing else is
  scanned.
- WWBOTA added to the catalogue with its 31 342 references.

Elsewhere: the rotor widget's Stop button acknowledges the press like
the direction presets already did, and the docked band map can be
switched to fit-to-band from its own header.
2026-08-21 01:07:10 +02:00
26 changed files with 251776 additions and 125 deletions
+65 -2
View File
@@ -1545,6 +1545,22 @@ func (a *App) GetStartupStatus() StartupStatus {
} }
} }
// armExitWatchdog guarantees the process dies.
//
// A graceful shutdown is preferable and is what everything below attempts; this
// only fires when that has already failed. By then the log says which step never
// returned, and the databases are either closed or safe to leave — SQLite's WAL
// survives a hard exit, which a zombie process does not.
func (a *App) armExitWatchdog(after time.Duration) {
go func() {
time.Sleep(after)
applog.Printf("shutdown: still not finished after %s — forcing exit "+
"(the last 'shutdown:' line above names the step that hung)", after)
applog.Close()
os.Exit(0)
}()
}
// beforeClose intercepts the window-close event so we can run shutdown // beforeClose intercepts the window-close event so we can run shutdown
// tasks (backup, future LoTW upload, ...) while showing a progress modal // tasks (backup, future LoTW upload, ...) while showing a progress modal
// to the user. Returns true the first time to block the close; the // to the user. Returns true the first time to block the close; the
@@ -1555,6 +1571,13 @@ func (a *App) beforeClose(ctx context.Context) bool {
return false return false
} }
a.shuttingDown = true a.shuttingDown = true
applog.Printf("shutdown: close requested")
// From here the application MUST end. Everything below waits on hardware and
// databases, and any one of them hanging used to leave a window that would
// not close and a process the operator had to kill from the task manager —
// which then blocked the next launch (single-instance guard) and the restart
// after an update. The watchdog is the promise that it ends anyway.
a.armExitWatchdog(30 * time.Second)
// Capture geometry now, before any shutdown UI can resize the window, so the // Capture geometry now, before any shutdown UI can resize the window, so the
// next launch reopens exactly here. // next launch reopens exactly here.
@@ -1636,6 +1659,7 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
steps[i].Status = "done" steps[i].Status = "done"
} }
a.emitShutdownEvent("shutdown:update", steps) a.emitShutdownEvent("shutdown:update", steps)
applog.Printf("shutdown: step %q %s %s", steps[i].ID, steps[i].Status, steps[i].Detail)
} }
a.emitShutdownEvent("shutdown:done", steps) a.emitShutdownEvent("shutdown:done", steps)
// Cleanly tear down hardware links before quitting. Especially the CAT // Cleanly tear down hardware links before quitting. Especially the CAT
@@ -1643,11 +1667,14 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
// TCP is gone, and just letting the process exit left the slot stale — the // TCP is gone, and just letting the process exit left the slot stale — the
// NEXT launch then spent ~30s retrying the connect before the radio freed it. // NEXT launch then spent ~30s retrying the connect before the radio freed it.
// cat.Stop() closes the socket with a proper FIN so the radio drops us at once. // cat.Stop() closes the socket with a proper FIN so the radio drops us at once.
applog.Printf("shutdown: stopping CAT")
if a.cat != nil { if a.cat != nil {
a.cat.Stop() a.cat.Stop()
} }
applog.Printf("shutdown: releasing relay drivers")
a.closeRelayDrivers() // release FTDI/serial relay handles for the next launch a.closeRelayDrivers() // release FTDI/serial relay handles for the next launch
time.Sleep(600 * time.Millisecond) time.Sleep(600 * time.Millisecond)
applog.Printf("shutdown: calling Quit")
wruntime.Quit(ctx) wruntime.Quit(ctx)
} }
@@ -1706,12 +1733,18 @@ func (a *App) shutdown(ctx context.Context) {
// If the user managed to skip beforeClose (force kill, OS shutdown, // If the user managed to skip beforeClose (force kill, OS shutdown,
// crash recovery) we still try the backup here as a best-effort // crash recovery) we still try the backup here as a best-effort
// safety net. HasBackupToday makes a double-run a no-op. // safety net. HasBackupToday makes a double-run a no-op.
applog.Printf("shutdown: teardown starting")
// Covers the paths beforeClose never saw (force kill of the window, OS
// shutdown) — and re-arms a shorter fuse for the teardown itself.
a.armExitWatchdog(15 * time.Second)
if !a.shuttingDown { if !a.shuttingDown {
a.maybeShutdownBackup() a.maybeShutdownBackup()
} }
applog.Printf("shutdown: stopping UDP")
if a.udp != nil { if a.udp != nil {
a.udp.StopAll() a.udp.StopAll()
} }
applog.Printf("shutdown: stopping solar")
if a.solar != nil { if a.solar != nil {
a.solar.Stop() a.solar.Stop()
} }
@@ -1719,6 +1752,7 @@ func (a *App) shutdown(ctx context.Context) {
// backend: without this the rig never gets a disconnect and holds its single // backend: without this the rig never gets a disconnect and holds its single
// control session for minutes, refusing every new login (even from the Icom // control session for minutes, refusing every new login (even from the Icom
// Remote Utility) until it times out on its own. // Remote Utility) until it times out on its own.
applog.Printf("shutdown: stopping CAT sharing")
if a.catShare != nil { if a.catShare != nil {
// Before the CAT stop, so no client is mid-command against a backend that // Before the CAT stop, so no client is mid-command against a backend that
// is disconnecting — and so the port is free for the next launch. // is disconnecting — and so the port is free for the next launch.
@@ -1729,29 +1763,35 @@ func (a *App) shutdown(ctx context.Context) {
a.catShareTCI.Stop() a.catShareTCI.Stop()
a.catShareTCI = nil a.catShareTCI = nil
} }
applog.Printf("shutdown: stopping CAT")
if a.cat != nil { if a.cat != nil {
a.cat.Stop() a.cat.Stop()
} }
applog.Printf("shutdown: stopping WinKeyer")
if a.winkeyer != nil { if a.winkeyer != nil {
a.winkeyer.Disconnect() a.winkeyer.Disconnect()
} }
applog.Printf("shutdown: stopping QSO recorder")
if a.qsoRec != nil { if a.qsoRec != nil {
a.qsoRec.Stop() a.qsoRec.Stop()
} }
// Before the databases: Close flushes what the last minute learnt, and a // Before the databases: Close flushes what the last minute learnt, and a
// restart is exactly when the grid cache is worth the most. // restart is exactly when the grid cache is worth the most.
applog.Printf("shutdown: closing grid cache")
if a.gridStore != nil { if a.gridStore != nil {
if err := a.gridStore.Close(); err != nil { if err := a.gridStore.Close(); err != nil {
applog.Printf("gridcache: close: %v", err) applog.Printf("gridcache: close: %v", err)
} }
a.gridStore = nil a.gridStore = nil
} }
applog.Printf("shutdown: closing databases")
if a.logDb != nil && a.logDb != a.db { if a.logDb != nil && a.logDb != a.db {
_ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB) _ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB)
} }
if a.db != nil { if a.db != nil {
_ = a.db.Close() _ = a.db.Close()
} }
applog.Printf("shutdown: teardown done")
} }
// userDataDir returns the OpsLog data directory: always "<exe dir>/data". // userDataDir returns the OpsLog data directory: always "<exe dir>/data".
@@ -12861,6 +12901,23 @@ func (a *App) consumeUDPEvents() {
// 15 s. Single-goroutine loop → the map needs no lock. // 15 s. Single-goroutine loop → the map needs no lock.
const decodeSpotWindow = 20 * time.Second const decodeSpotWindow = 20 * time.Second
lastDecodeSpot := map[string]time.Time{} lastDecodeSpot := map[string]time.Time{}
// Panadapter spots go out on their OWN goroutine.
//
// SendSpot writes to the radio's TCP socket, and this loop is the only
// consumer of the UDP event channel — which drops on backpressure. A period
// hands over twenty-odd decodes at once, so one slow write to the Flex was
// enough to fill that queue and lose decodes the panel was meant to show.
// The spot is an ornament; the decode is the point. Buffered and
// non-blocking, so a radio that stops reading costs spots and nothing else.
spotQ := make(chan cat.SpotInfo, 64)
go func() {
for si := range spotQ {
if a.cat != nil {
a.cat.SendSpot(si)
}
}
}()
defer close(spotQ)
for ev := range a.udp.Events() { for ev := range a.udp.Events() {
if a.ctx == nil { if a.ctx == nil {
continue continue
@@ -12954,14 +13011,20 @@ func (a *App) consumeUDPEvents() {
if secs <= 0 { if secs <= 0 {
secs = 120 secs = 120
} }
a.cat.SendSpot(cat.SpotInfo{ select {
case spotQ <- cat.SpotInfo{
FreqHz: ev.DecodeFreqHz, FreqHz: ev.DecodeFreqHz,
Callsign: ev.DecodeCall, Callsign: ev.DecodeCall,
Mode: ev.Mode, Mode: ev.Mode,
Color: "#FF34C759", // green — distinct from cluster orange Color: "#FF34C759", // green — distinct from cluster orange
Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR),
LifetimeSec: secs, LifetimeSec: secs,
}) }:
default:
// Radio not keeping up: skip the spot rather than stall the
// decode stream behind it.
delete(lastDecodeSpot, key) // not spotted after all — let the next one try
}
} }
case ev.LoggedADIF != "": case ev.LoggedADIF != "":
// The RECORD, not just its size. "Was the grid in what WSJT-X sent, or // The RECORD, not just its size. "Was the grid in what WSJT-X sent, or
+13 -1
View File
@@ -716,7 +716,10 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
} }
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q) vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
if q.FreqHz != nil { if q.FreqHz != nil {
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz" // Megahertz, without the unit. The column is labelled FREQ and the box is
// narrow — "10.136 MHz" ran into the MODE column beside it, and a card that
// says FREQ 10.136 has never confused anybody.
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64)
} }
return vars, a.qslCountryInfo(), nil return vars, a.qslCountryInfo(), nil
} }
@@ -749,12 +752,21 @@ func (a *App) qslCountryInfo() qslcard.CountryInfo {
} }
// sampleQSO is the canned contact used by the editor's live preview. // sampleQSO is the canned contact used by the editor's live preview.
//
// Every field the QSO box can show must be FILLED here, and filled with a value
// of realistic width. The preview is the only place an operator can see whether
// a column fits before printing a card — a blank one silently promises room it
// will not have. The frequency is the widest of them, hence a real 20 m SSB one
// rather than a round number.
func sampleQSO() qso.QSO { func sampleQSO() qso.QSO {
hz := int64(14285000)
return qso.QSO{ return qso.QSO{
Callsign: "DL1ABC", Callsign: "DL1ABC",
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC), QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
Band: "20m", Band: "20m",
Mode: "SSB", Mode: "SSB",
Submode: "USB",
FreqHz: &hz,
RSTSent: "59", RSTSent: "59",
QSLMsg: "TNX FB QSO — 73!", QSLMsg: "TNX FB QSO — 73!",
} }
+17
View File
@@ -49,6 +49,16 @@ type RowColorSettings struct {
// Configured distinguishes "saved with this off" from "saved before the // Configured distinguishes "saved with this off" from "saved before the
// option existed", so a new marker can default ON without silently turning // option existed", so a new marker can default ON without silently turning
// itself back on for an operator who switched it off. // itself back on for an operator who switched it off.
// RecentZebra decides whether the Recent QSOs grid alternates its row
// background. "" (or "on") keeps the banding, which is what the grid has
// always done; "off" makes every row the same colour.
//
// A string with an empty default rather than a bool: a bool would have to
// mean "true when absent", and every reader would have to remember that.
RecentZebra string `json:"recent_zebra,omitempty"`
// RecentZebraColor is the alternate row's colour. Empty follows the theme,
// which is the only answer that stays right across all four of them.
RecentZebraColor string `json:"recent_zebra_color,omitempty"`
Configured bool `json:"configured"` Configured bool `json:"configured"`
Rules []RowColorRule `json:"rules"` Rules []RowColorRule `json:"rules"`
} }
@@ -94,6 +104,13 @@ func normRowColors(s RowColorSettings) RowColorSettings {
out := RowColorSettings{ out := RowColorSettings{
Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity, Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity,
BandMapLotw: s.BandMapLotw, Configured: true, BandMapLotw: s.BandMapLotw, Configured: true,
RecentZebra: strings.ToLower(strings.TrimSpace(s.RecentZebra)),
}
if out.RecentZebra != "off" {
out.RecentZebra = "" // anything unrecognised means the default banding
}
if c := strings.TrimSpace(s.RecentZebraColor); hexColor.MatchString(c) {
out.RecentZebraColor = c
} }
if !s.Configured { if !s.Configured {
out.BandMapLotw = true // new option, on unless the operator says otherwise out.BandMapLotw = true // new option, on unless the operator says otherwise
+44
View File
@@ -1,4 +1,48 @@
[ [
{
"version": "0.26.1",
"date": "",
"en": [
"Auto-call: a QSO in progress is now held by OpsLog itself, so a new station is no longer called in the middle of an exchange. The hold is released when that QSO is logged, when you halt or click another decode, and after four minutes without a contact.",
"Rotor widget: the Stop button now lights up when pressed, like the direction presets, so there is no doubt the order went out.",
"FT decodes: a decode could go missing from the panel when a period arrived as one burst — the internal queue discarded it in silence. It is now deep enough for a full period, panadapter spots no longer hold the stream up, and anything still lost is written to the log.",
"Closing OpsLog can no longer leave a process behind. Each shutdown step is written to the log, and if one of them hangs the application now forces its own exit instead of staying open — which also unblocks the restart after an update.",
"CAT: a rig link whose polling loop got stuck no longer freezes every later reconnection attempt in silence. The wait is now bounded and the problem is written to the log, instead of a radio that never comes back and no explanation anywhere.",
"Cluster console: the replies to your commands are no longer buried under the spot flood. A Replies button — on by default — hides the DX spots, which the list above already shows.",
"Cluster: twelve named command buttons beside the command box — name one \"Show filters\", give it SH/FILTER, and one click sends it. They are set up in Settings → Cluster; a button with no command is not shown.",
"Cluster console: a Follow button decides whether the view sticks to the newest line, and sending a command re-arms it — a long reply is now scrolled to instead of landing out of sight. Switch Follow off to read without being pulled back down; the arrow beside it returns to the end.",
"Band map: the docked map can be switched to fit-to-band from its own header — click the px/kHz readout. It is the same setting the Band Map tab drives, so the two always agree.",
"Awards: an award can now be set to search in NO field. The matching options disappear with it, and the award counts only the references you assign to a contact by hand — for a reference like WWBOTA, which has no ADIF field anywhere.",
"New award: WWBOTA (World Wide Bunkers on the Air), with its full reference list — 31 342 bunkers. References are assigned to a contact by hand, the award having no ADIF field of its own.",
"Grid squares map: the map now fills its panel. It measured its container once, while the tab was still hidden, and kept that size — leaving a blank band under it whatever the window size.",
"Grid squares map: the fill colours for confirmed and worked squares can be chosen, beside the basemap selector. Left alone they follow the theme, and one click puts them back.",
"QSL cards: the QSO box now carries the frequency in megahertz alongside the band, and the designer lets you choose which columns it shows — date, UTC, band, frequency, mode, submode, RST.",
"Main view: up to four panes instead of two. A third and a fourth can be chosen — or left empty, in which case they are not drawn — and with four you pick between quarters (2 × 2) and four columns. Every divider is draggable, each arrangement is remembered for its own pane count, and a double-click evens it out.",
"Appearance: the alternating row shading in Recent QSOs can be switched off — every row the same colour — or given a colour of its own. It is independent of the QSL row colours, which still take precedence on the rows they paint.",
"QSL row colours: the \"left stripe\" style no longer draws a coloured hairline along the top and bottom of every coloured row. The stripe is painted differently — it is a stripe again, and nothing else.",
"QSL designer: the preview contact now fills every column the QSO box can show, frequency and submode included. A blank column promised room the printed card would not have."
],
"fr": [
"Appel automatique : le QSO en cours est désormais verrouillé par OpsLog lui-même, si bien qu'une nouvelle station n'est plus appelée au milieu d'un échange. Le verrou est levé quand ce QSO est journalisé, quand tu fais Stop ou cliques un autre décodage, et au bout de quatre minutes sans contact.",
"Widget rotor : le bouton Stop s'allume maintenant quand on l'enfonce, comme les présélections de direction, pour ne plus douter que l'ordre est parti.",
"Décodages FT : un décodage pouvait manquer dans le panneau quand une période arrivait d'un bloc — la file interne le jetait sans rien dire. Elle est désormais dimensionnée pour une période entière, les spots panadapter ne retiennent plus le flux, et ce qui serait encore perdu est écrit dans le journal.",
"Fermer OpsLog ne peut plus laisser un processus derrière. Chaque étape d'arrêt est écrite dans le journal, et si l'une d'elles se bloque l'application force désormais sa propre sortie au lieu de rester ouverte — ce qui débloque aussi le redémarrage après une mise à jour.",
"CAT : une liaison radio dont la boucle d'interrogation se bloquait figeait en silence toutes les tentatives de reconnexion suivantes. L'attente est désormais bornée et le problème écrit dans le journal, au lieu d'une radio qui ne revient jamais sans la moindre explication.",
"Console cluster : les réponses à tes commandes ne sont plus noyées sous le flot de spots. Un bouton Réponses — actif par défaut — masque les spots DX, que la liste au-dessus affiche déjà.",
"Cluster : douze boutons de commande nommés à côté du champ de saisie — nomme-en un « Voir filtres », donne-lui SH/FILTER, et un clic l'envoie. Ils se règlent dans Réglages → Cluster ; un bouton sans commande n'est pas affiché.",
"Console cluster : un bouton Suivre décide si la vue reste collée à la dernière ligne, et envoyer une commande le réarme — une longue réponse défile maintenant jusqu'à elle au lieu d'arriver hors champ. Désactive Suivre pour lire sans être ramené en bas ; la flèche à côté revient à la fin.",
"Bandmap : la carte ancrée peut passer en ajustement à la bande depuis son propre en-tête — cliquer l'indicateur px/kHz. C'est le même réglage que celui de l'onglet Bandmap, les deux sont donc toujours d'accord.",
"Diplômes : un diplôme peut désormais ne chercher dans AUCUN champ. Les options de reconnaissance disparaissent avec lui, et le diplôme ne compte que les références attribuées à la main sur un contact — pour une référence comme WWBOTA, qui n'a de champ ADIF nulle part.",
"Nouveau diplôme : WWBOTA (World Wide Bunkers on the Air), avec sa liste de références complète — 31 342 bunkers. Les références s'attribuent à la main sur un contact, le diplôme n'ayant pas de champ ADIF propre.",
"Carte des carrés locator : la carte occupe enfin tout son panneau. Elle mesurait son conteneur une seule fois, alors que l'onglet était encore masqué, et gardait cette taille — d'où la bande vide en dessous quelle que soit la taille de la fenêtre.",
"Carte des carrés locator : les couleurs des carrés confirmés et contactés se choisissent, à côté du sélecteur de fond de carte. Laissées telles quelles, elles suivent le thème, et un clic les y ramène.",
"Cartes QSL : le tableau du QSO porte désormais la fréquence en mégahertz à côté de la bande, et le concepteur permet de choisir les colonnes affichées — date, UTC, bande, fréquence, mode, sous-mode, RST.",
"Vue principale : jusqu'à quatre volets au lieu de deux. Un troisième et un quatrième se choisissent — ou se laissent vides, auquel cas ils ne sont pas dessinés — et avec quatre tu choisis entre les quarts (2 × 2) et quatre colonnes. Chaque séparateur se glisse, chaque disposition est mémorisée pour son propre nombre de volets, et un double-clic la remet à égalité.",
"Apparence : l'alternance de teinte une ligne sur deux dans QSO récents peut être désactivée — toutes les lignes de la même couleur — ou recevoir sa propre couleur. Elle est indépendante des couleurs de ligne QSL, qui restent prioritaires sur les lignes qu'elles peignent.",
"Couleurs de ligne QSL : le style « barre à gauche » ne trace plus un filet coloré en haut et en bas de chaque ligne colorée. La barre est peinte autrement — c'est de nouveau une barre, et rien d'autre.",
"Concepteur QSL : le contact d'aperçu remplit maintenant toutes les colonnes que le tableau peut afficher, fréquence et sous-mode compris. Une colonne vide promettait une place que la carte imprimée n'avait pas."
]
},
{ {
"version": "0.26.0", "version": "0.26.0",
"date": "", "date": "",
+352 -39
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock, Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
@@ -110,6 +110,7 @@ import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/Winkeye
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { GridSquareMap } from '@/components/GridSquareMap'; import { GridSquareMap } from '@/components/GridSquareMap';
import { loadAutoCall, shouldAutoCall, autoCallKey, type AutoCallSettings } from '@/lib/autocall'; import { loadAutoCall, shouldAutoCall, autoCallKey, type AutoCallSettings } from '@/lib/autocall';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel'; import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading'; import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
@@ -943,14 +944,55 @@ export default function App() {
}); });
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]); useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
// Main tab: how much width the LEFT pane gets, in percent. Clamped to 15..85 // Main tab column shares, in percent, kept PER COLUMN COUNT.
// so a pane can be made small but never dragged out of existence — recovering //
// from that means finding a divider that is no longer on screen. // One list per count and not a single one that is rescaled: going from three
const [mainSplit, setMainSplit] = useState<number>(() => { // panes to two and back should give you the three-pane layout you arranged,
const n = parseFloat(localStorage.getItem('opslog.mainSplit') || ''); // not something derived from the two-pane one. The old single 'mainSplit' key
return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50; // is still read so an existing two-pane arrangement survives the upgrade.
//
// MIN_SHARE is what keeps a pane recoverable: a column dragged to nothing
// takes its divider off screen with it, and there is then no way back.
const MIN_SHARE = 10;
const evenShares = (n: number) => Array.from({ length: n }, () => 100 / n);
const [mainShares, setMainShares] = useState<Record<number, number[]>>(() => {
const out: Record<number, number[]> = { 2: evenShares(2), 3: evenShares(3), 4: evenShares(4) };
try {
const v = JSON.parse(localStorage.getItem('opslog.mainShares') || '{}');
for (const n of [2, 3, 4]) {
const a = v?.[n];
if (Array.isArray(a) && a.length === n && a.every((x: any) => Number.isFinite(x) && x >= MIN_SHARE)) {
out[n] = a.map(Number);
}
}
} catch { /* a corrupt preference must not cost the layout */ }
// Legacy: the two-pane percentage from before there were more than two.
const old = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
if (!Array.isArray(JSON.parse(localStorage.getItem('opslog.mainShares') || 'null')?.[2])
&& Number.isFinite(old) && old >= MIN_SHARE && old <= 100 - MIN_SHARE) {
out[2] = [old, 100 - old];
}
return out;
}); });
useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]); const saveShares = (next: Record<number, number[]>) => {
setMainShares(next);
writeUiPref('opslog.mainShares', JSON.stringify(next));
};
// The quadrant cross: one vertical split and one horizontal split, each
// shared by both rows/columns. Two independent row heights would let the
// panes stop lining up, which reads as a rendering fault rather than a choice.
const [mainQuad, setMainQuad] = useState<{ col: number; row: number }>(() => {
try {
const v = JSON.parse(localStorage.getItem('opslog.mainQuad') || '{}');
const ok = (x: any) => Number.isFinite(x) && x >= MIN_SHARE && x <= 100 - MIN_SHARE;
if (ok(v?.col) && ok(v?.row)) return { col: Number(v.col), row: Number(v.row) };
} catch { /* same */ }
return { col: 50, row: 50 };
});
const saveQuad = (next: { col: number; row: number }) => {
setMainQuad(next);
writeUiPref('opslog.mainQuad', JSON.stringify(next));
};
// Band-map widths. Two of them, because they are two different things: the // Band-map widths. Two of them, because they are two different things: the
// docked map sits beside the tables and competes with them for room, while // docked map sits beside the tables and competes with them for room, while
@@ -995,22 +1037,60 @@ export default function App() {
}; };
const mainSplitRef = useRef<HTMLDivElement | null>(null); const mainSplitRef = useRef<HTMLDivElement | null>(null);
const startMainSplitDrag = (e: React.PointerEvent) => { // Drag the divider between column i and column i+1.
//
// Only those two columns change, and their SUM is held constant: pushing a
// divider must not shuffle the columns further along, which is what makes a
// three-way split impossible to arrange one divider at a time.
const startMainSplitDrag = (e: React.PointerEvent, i: number, count: number) => {
e.preventDefault(); e.preventDefault();
const host = mainSplitRef.current; const host = mainSplitRef.current;
if (!host) return; if (!host) return;
// Pointer capture on the divider, so dragging over a map keeps working — // Pointer capture on the divider, so dragging over a map keeps working —
// Leaflet would otherwise swallow the moves the moment the cursor entered it. // Leaflet would otherwise swallow the moves the moment the cursor entered it.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const start = (mainShares[count] ?? evenShares(count)).slice();
const pair = start[i] + start[i + 1];
const before = start.slice(0, i).reduce((a, b) => a + b, 0);
const onMove = (ev: PointerEvent) => { const onMove = (ev: PointerEvent) => {
const r = host.getBoundingClientRect(); const r = host.getBoundingClientRect();
if (r.width <= 0) return; if (r.width <= 0) return;
const pct = ((ev.clientX - r.left) / r.width) * 100; const pct = ((ev.clientX - r.left) / r.width) * 100;
setMainSplit(Math.min(85, Math.max(15, pct))); const first = Math.min(pair - MIN_SHARE, Math.max(MIN_SHARE, pct - before));
const next = start.slice();
next[i] = first;
next[i + 1] = pair - first;
setMainShares((m) => ({ ...m, [count]: next }));
}; };
const onUp = () => { const onUp = () => {
window.removeEventListener('pointermove', onMove); window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp); window.removeEventListener('pointerup', onUp);
// Persisted on release, not on every move: the pointer fires dozens of
// events a second and each one would be a database write.
setMainShares((m) => { writeUiPref('opslog.mainShares', JSON.stringify(m)); return m; });
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
// The quadrant cross. Same shape, one axis at a time.
const startQuadDrag = (e: React.PointerEvent, axis: 'col' | 'row') => {
e.preventDefault();
const host = mainSplitRef.current;
if (!host) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
const r = host.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return;
const pct = axis === 'col'
? ((ev.clientX - r.left) / r.width) * 100
: ((ev.clientY - r.top) / r.height) * 100;
const v = Math.min(100 - MIN_SHARE, Math.max(MIN_SHARE, pct));
setMainQuad((q) => ({ ...q, [axis]: v }));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
setMainQuad((q) => { writeUiPref('opslog.mainQuad', JSON.stringify(q)); return q; });
}; };
window.addEventListener('pointermove', onMove); window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp); window.addEventListener('pointerup', onUp);
@@ -1499,7 +1579,42 @@ export default function App() {
const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever
const [clusterLines, setClusterLines] = useState<ClusterLine[]>([]); const [clusterLines, setClusterLines] = useState<ClusterLine[]>([]);
const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false); const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false);
// Hide the spot flood in the console, on by default.
//
// The spots have a grid of their own two panels up; what the console is FOR is
// the traffic that is not a spot — the reply to SH/DX, the filter listing, the
// node's complaint about a command it did not understand. On a busy node those
// answers scrolled out of sight in under a second, which made the command box
// look inert even though it had worked.
const [clusterHideSpots, setClusterHideSpots] = useState(() => lsBool('opslog.clusterHideSpots', true));
// Follow the tail, or stay where you put it.
//
// The console used to follow the bottom whenever you happened to be at the
// bottom, which is not the same as being asked to: reading a long SH/DX reply
// meant scrolling up and hoping nothing pulled you back. This is the explicit
// switch — off means the view never moves on its own, however much traffic
// arrives, and the button below jumps back down when you want it.
const [clusterFollow, setClusterFollow] = useState(() => lsBool('opslog.clusterConsoleFollow', true));
// What the console actually renders. A spot line is the node's own "DX de …"
// announcement — the one shape every cluster software agrees on — and it is
// matched on that alone: guessing more (WCY, WWV, a node's chatter) would hide
// traffic the operator asked to see. Lines WE sent are never hidden; they are
// the anchor for reading the reply that follows.
const clusterShown = useMemo(
() => (clusterHideSpots ? clusterLines.filter((l) => l.sent || !/^\s*DX de /i.test(l.text)) : clusterLines),
[clusterLines, clusterHideSpots],
);
const clusterConsoleRef = useRef<HTMLDivElement | null>(null); const clusterConsoleRef = useRef<HTMLDivElement | null>(null);
// Whether the view was at the bottom BEFORE the new lines landed.
//
// Measuring it inside the effect — after React had committed them — is what
// broke following a command reply: a ten-line answer to SH/FILTER makes the
// distance to the bottom jump past any threshold in the same frame, so the
// console concluded the operator had scrolled up and stayed where it was.
// Exactly the case the feature exists for. The scroll event is the only
// honest source: it fires when the OPERATOR moves, and not when content grows
// underneath them.
const clusterAtBottomRef = useRef(true);
// Console lines are STAGED and flushed on a timer, never applied one by one. // Console lines are STAGED and flushed on a timer, never applied one by one.
// //
// Every line of cluster traffic reaches this handler — spots, MOTD, WHO, the // Every line of cluster traffic reaches this handler — spots, MOTD, WHO, the
@@ -1540,11 +1655,26 @@ export default function App() {
// Follow the tail, but ONLY when already at the bottom — otherwise scrolling up // Follow the tail, but ONLY when already at the bottom — otherwise scrolling up
// to read a SH/DX reply would yank you back down on the next spot. // to read a SH/DX reply would yank you back down on the next spot.
useEffect(() => { useEffect(() => {
if (!clusterFollow) return; // pinned by the operator — never move the view
// Even while following, a view the operator scrolled up is left alone: they
// are mid-sentence, and scrolling back to the bottom re-arms it by itself.
if (!clusterAtBottomRef.current) return;
const el = clusterConsoleRef.current; const el = clusterConsoleRef.current;
if (!el) return; if (el) el.scrollTop = el.scrollHeight;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; // clusterShown, not clusterLines: with the spots hidden, the rendered list
if (atBottom) el.scrollTop = el.scrollHeight; // is the only thing whose change can move the view.
}, [clusterLines]); }, [clusterShown, clusterFollow]);
const clusterScrollToBottom = () => {
clusterAtBottomRef.current = true;
const el = clusterConsoleRef.current;
if (el) el.scrollTop = el.scrollHeight;
};
// Sending a command is a request to SEE its answer: it re-arms the follow
// whatever the scroll position was, then the reply lands and is followed.
const clusterSend = (cmd: string) => {
clusterScrollToBottom();
return SendClusterCommand(cmd).catch((err) => setError(String(err?.message ?? err)));
};
// Multi-band filter: empty set = all bands. The user toggles chips. // Multi-band filter: empty set = all bands. The user toggles chips.
const [clusterBands, setClusterBands] = useState<Set<string>>(() => lsSet<string>('opslog.clusterBands')); const [clusterBands, setClusterBands] = useState<Set<string>>(() => lsSet<string>('opslog.clusterBands'));
// Lock-to-entry: when on, the band filter follows the entry's current // Lock-to-entry: when on, the band filter follows the entry's current
@@ -1674,18 +1804,32 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the // map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below. // so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol' | 'decodes'; // 'none' is only ever stored for the third and fourth panes: the first two are
// the Main view, and a layout with no panes at all is not a layout.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol' | 'decodes' | 'none';
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1'); const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2'); const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const [mainPane3, setMainPane3] = useState<MainPaneKind>('none');
const [mainPane4, setMainPane4] = useState<MainPaneKind>('none');
// With four panes: side by side, or the screen quartered. Both are wanted —
// four columns on a 65" desk display, quadrants on an ordinary one, where a
// quarter-width map is unreadable.
const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad');
const loadMainPanes = useCallback(async () => { const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol' || v === 'decodes'; const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol' || v === 'decodes';
const [l, r] = await Promise.all([ const [l, r, p3, p4, lay] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''),
GetUIPref('mainPane3').catch(() => ''),
GetUIPref('mainPane4').catch(() => ''),
GetUIPref('mainPaneLayout').catch(() => ''),
]); ]);
setMainPaneLeft(valid(l) ? l : 'map1'); setMainPaneLeft(valid(l) ? l : 'map1');
setMainPaneRight(valid(r) ? r : 'map2'); setMainPaneRight(valid(r) ? r : 'map2');
setMainPane3(valid(p3) ? p3 : 'none');
setMainPane4(valid(p4) ? p4 : 'none');
setMainLayout4(lay === 'cols' ? 'cols' : 'quad');
}, []); }, []);
useEffect(() => { loadMainPanes(); }, [loadMainPanes]); useEffect(() => { loadMainPanes(); }, [loadMainPanes]);
// Report the current entry-strip band/mode/freq to the backend so the live // Report the current entry-strip band/mode/freq to the backend so the live
@@ -1803,15 +1947,17 @@ export default function App() {
const spotsVisibleRef = useRef(false); const spotsVisibleRef = useRef(false);
const spotsDirtyRef = useRef(false); const spotsDirtyRef = useRef(false);
useEffect(() => { useEffect(() => {
const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster' // Every pane, not just the first two — a cluster list in the third pane is
|| mainPaneLeft === 'decodes' || mainPaneRight === 'decodes' // just as visible, and missing it left a spot's NEW pill stale.
const shown = [mainPaneLeft, mainPaneRight, mainPane3, mainPane4];
const vis = shown.includes('cluster') || shown.includes('decodes')
|| activeTab === 'cluster' || activeTab === 'bandmap' || activeTab === 'decodes' || showBandMap; || activeTab === 'cluster' || activeTab === 'bandmap' || activeTab === 'decodes' || showBandMap;
if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) { if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) {
spotsDirtyRef.current = false; spotsDirtyRef.current = false;
void refreshSpotStatuses(); void refreshSpotStatuses();
} }
spotsVisibleRef.current = vis; spotsVisibleRef.current = vis;
}, [mainPaneLeft, mainPaneRight, activeTab, showBandMap, refreshSpotStatuses]); }, [mainPaneLeft, mainPaneRight, mainPane3, mainPane4, activeTab, showBandMap, refreshSpotStatuses]);
useEffect(() => { useEffect(() => {
let t: number | undefined; let t: number | undefined;
const off = EventsOn('qso:logged', () => { const off = EventsOn('qso:logged', () => {
@@ -2100,6 +2246,11 @@ export default function App() {
// argued with); this is only the plumbing that runs it and keys the radio. // argued with); this is only the plumbing that runs it and keys the radio.
// //
// Re-read when Preferences closes, like every other setting edited there. // Re-read when Preferences closes, like every other setting edited there.
// The named command buttons, re-read when Preferences closes like every other
// setting edited there.
const [clusterMacros, setClusterMacros] = useState(loadClusterMacros);
useEffect(() => { if (!showSettings) setClusterMacros(loadClusterMacros()); }, [showSettings]);
const clusterMacrosShown = useMemo(() => visibleClusterMacros(clusterMacros), [clusterMacros]);
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall); const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]); useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]);
// When each callsign was last answered, so a station still calling CQ is not // When each callsign was last answered, so a station still calling CQ is not
@@ -2112,6 +2263,22 @@ export default function App() {
// Set when a call goes out, so nothing else fires until the receiver's own // Set when a call goes out, so nothing else fires until the receiver's own
// status catches up and `busy` can be trusted again. // status catches up and `busy` can be trusted again.
const autoHoldUntilRef = useRef(0); const autoHoldUntilRef = useRef(0);
// The station auto-call is currently working, and when it started.
//
// This is OpsLog's OWN record of "a QSO is running", and it exists because
// deriving that from the sender's Status was not enough: the moment
// WSJT-X/JTDX drops the DX call or the Enable-Tx flag between overs — which
// they do — the exchange looks finished and the next CQ gets answered,
// interleaving two and then three QSOs on one slice. A lock we set ourselves
// cannot be cleared by a flag we do not control.
//
// Released when that station's QSO is logged, when the operator halts or
// takes over by clicking a decode, and by the watchdog below.
const autoTargetRef = useRef<{ call: string; at: number } | null>(null);
// An exchange abandoned mid-way must not lock auto-call out for ever: four
// minutes covers a repeated FT8 QSO and still frees the next period soon
// enough to matter.
const AUTO_TARGET_MAX_MS = 240_000;
// Per receiver, when the carrier was last up. Feeds the stale-exchange // Per receiver, when the carrier was last up. Feeds the stale-exchange
// backstop below. // backstop below.
const lastTxAtRef = useRef<Map<string, number>>(new Map()); const lastTxAtRef = useRef<Map<string, number>>(new Map());
@@ -2141,6 +2308,13 @@ export default function App() {
// it has acted on it — about a second. Without it the OTHER instance still // it has acted on it — about a second. Without it the OTHER instance still
// looks idle in that window and gets a call of its own. // looks idle in that window and gets a call of its own.
|| now < autoHoldUntilRef.current; || now < autoHoldUntilRef.current;
// Our own lock, evaluated after the watchdog so an abandoned exchange does
// not hold the transmitter shut.
if (autoTargetRef.current && now - autoTargetRef.current.at > AUTO_TARGET_MAX_MS) {
LogUIError('auto-call', `giving up on ${autoTargetRef.current.call} — nothing logged in four minutes`, '');
autoTargetRef.current = null;
}
const locked = busy || autoTargetRef.current !== null;
for (const d of decodes) { for (const d of decodes) {
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`; const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
if (autoSeenRef.current.has(seenKey)) continue; if (autoSeenRef.current.has(seenKey)) continue;
@@ -2155,7 +2329,7 @@ export default function App() {
if (!e) continue; if (!e) continue;
autoSeenRef.current.add(seenKey); autoSeenRef.current.add(seenKey);
const verdict = shouldAutoCall(autoCall, d, e as any, { const verdict = shouldAutoCall(autoCall, d, e as any, {
busy, busy: locked,
calledAt: autoCalledRef.current, calledAt: autoCalledRef.current,
now, now,
myCall: station.callsign, myCall: station.callsign,
@@ -2163,6 +2337,7 @@ export default function App() {
if (!verdict.call) continue; if (!verdict.call) continue;
autoCalledRef.current.set(d.call.toUpperCase(), now); autoCalledRef.current.set(d.call.toUpperCase(), now);
autoHoldUntilRef.current = now + 12_000; // an FT8 slot, near enough autoHoldUntilRef.current = now + 12_000; // an FT8 slot, near enough
autoTargetRef.current = { call: d.call.toUpperCase(), at: now };
// Same reason as a manual click: put the transmitter on the decode's band // Same reason as a manual click: put the transmitter on the decode's band
// before answering, or a second slice answers on the wrong one. // before answering, or a second slice answers on the wrong one.
FlexTXOnBand(d.band ?? '').catch(() => {}); FlexTXOnBand(d.band ?? '').catch(() => {});
@@ -3348,6 +3523,11 @@ export default function App() {
try { try {
await LogUDPLoggedADIF(text); await LogUDPLoggedADIF(text);
await refresh(); await refresh();
// The QSO auto-call started has finished — release the lock so the next
// CQ can be answered. Matched on the callsign: a QSO logged from
// somewhere else must not free a run that is still going.
const logged = /<call:d+(?::[^>]*)?>([^<s]+)/i.exec(text)?.[1]?.toUpperCase();
if (logged && autoTargetRef.current?.call === logged) autoTargetRef.current = null;
} catch (e: any) { } catch (e: any) {
const msg = String(e?.message ?? e); const msg = String(e?.message ?? e);
// A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign — // A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
@@ -5609,6 +5789,9 @@ export default function App() {
// who gets answered — and it would only fight the digital application // who gets answered — and it would only fight the digital application
// for the VFO. The entry is still filled, so the QSO can be logged here. // for the VFO. The entry is still filled, so the QSO can be logged here.
onCall={(d) => { onCall={(d) => {
// The operator has picked a station: that is now the QSO in progress, so
// auto-call must not answer someone else over the top of it.
autoTargetRef.current = { call: (d.call ?? '').toUpperCase(), at: Date.now() };
onCallsignInput(d.call, { force: true }); onCallsignInput(d.call, { force: true });
// With two slices on two bands, the Reply reaches the right INSTANCE but // With two slices on two bands, the Reply reaches the right INSTANCE but
// the radio still transmits on whichever slice holds the TX flag. Move // the radio still transmits on whichever slice holds the TX flag. Move
@@ -5627,6 +5810,7 @@ export default function App() {
// buffer goes too or the next flush would put back what was just cleared. // buffer goes too or the next flush would put back what was just cleared.
onClear={(instance) => { onClear={(instance) => {
if (!instance) { if (!instance) {
autoTargetRef.current = null;
pendingDecodesRef.current = []; pendingDecodesRef.current = [];
setDecodes([]); setDecodes([]);
setTxMsgs([]); setTxMsgs([]);
@@ -5644,6 +5828,8 @@ export default function App() {
// An empty instance lets the backend fall back to whichever application // An empty instance lets the backend fall back to whichever application
// last reported its status — the normal single-receiver case. // last reported its status — the normal single-receiver case.
onHalt={(instance) => { onHalt={(instance) => {
// Halt means stop, including whatever auto-call had started.
autoTargetRef.current = null;
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e))); HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
}} }}
/> />
@@ -7315,8 +7501,35 @@ export default function App() {
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('cluster.console')} {t('cluster.console')}
</span> </span>
<span className="text-[10px] text-muted-foreground tabular-nums">{clusterLines.length}</span> <span className="text-[10px] text-muted-foreground tabular-nums">{clusterShown.length}</span>
<div className="flex-1" /> <div className="flex-1" />
<button
className={cn('text-[11px] px-1.5 rounded border',
clusterHideSpots
? 'border-primary/60 bg-primary/15 text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground')}
title={t('cluster.repliesOnlyTip')}
onClick={() => {
const v = !clusterHideSpots;
setClusterHideSpots(v);
writeUiPref('opslog.clusterHideSpots', v ? '1' : '0');
}}>{t('cluster.repliesOnly')}</button>
<button
className={cn('text-[11px] px-1.5 rounded border',
clusterFollow
? 'border-primary/60 bg-primary/15 text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground')}
title={t('cluster.followTip')}
onClick={() => {
const v = !clusterFollow;
setClusterFollow(v);
writeUiPref('opslog.clusterConsoleFollow', v ? '1' : '0');
if (v) clusterScrollToBottom(); // switching it on means "take me there"
}}>{t('cluster.follow')}</button>
<button
className="text-[11px] px-1.5 rounded border border-transparent text-muted-foreground hover:text-foreground"
title={t('cluster.toBottom')}
onClick={clusterScrollToBottom}></button>
<button className="text-[11px] text-muted-foreground hover:text-foreground" <button className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => setClusterLines([])}>{t('cluster.clear')}</button> onClick={() => setClusterLines([])}>{t('cluster.clear')}</button>
<button className="text-muted-foreground hover:text-foreground" <button className="text-muted-foreground hover:text-foreground"
@@ -7324,10 +7537,17 @@ export default function App() {
<X className="size-3.5" /> <X className="size-3.5" />
</button> </button>
</div> </div>
<div ref={clusterConsoleRef} className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]"> <div ref={clusterConsoleRef}
{clusterLines.length === 0 ? ( onScroll={(e) => {
<p className="text-muted-foreground italic">{t('cluster.consoleEmpty')}</p> const el = e.currentTarget;
) : clusterLines.map((l, i) => ( clusterAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
{clusterShown.length === 0 ? (
<p className="text-muted-foreground italic">
{t(clusterHideSpots && clusterLines.length > 0 ? 'cluster.consoleOnlySpots' : 'cluster.consoleEmpty')}
</p>
) : clusterShown.map((l, i) => (
<div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}> <div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}>
<span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span> <span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span>
{l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>} {l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>}
@@ -7348,15 +7568,13 @@ export default function App() {
)} )}
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span> <span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span>
<Input <Input
className="font-mono text-xs h-8" className="font-mono text-xs h-8 w-64 shrink-0"
placeholder='sh/dx 30, set/needsdxcc, …' placeholder='sh/dx 30, set/needsdxcc, …'
value={clusterCmd} value={clusterCmd}
onChange={(e) => setClusterCmd(e.target.value)} onChange={(e) => setClusterCmd(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && clusterCmd.trim()) { if (e.key === 'Enter' && clusterCmd.trim()) {
SendClusterCommand(clusterCmd.trim()) clusterSend(clusterCmd.trim()).then(() => setClusterCmd(''));
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
} }
}} }}
/> />
@@ -7364,14 +7582,38 @@ export default function App() {
variant="outline" size="sm" variant="outline" size="sm"
onClick={() => { onClick={() => {
if (!clusterCmd.trim()) return; if (!clusterCmd.trim()) return;
SendClusterCommand(clusterCmd.trim()) clusterSend(clusterCmd.trim()).then(() => setClusterCmd(''));
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
}} }}
disabled={!clusterCmd.trim()} disabled={!clusterCmd.trim()}
> >
Send Send
</Button> </Button>
{/* The macro buttons, in the space the command box used to take up
for itself. After Send, so the two ways of sending a command
are not interleaved and the eye finds Send where it has always
been. Wraps rather than scrolls: twelve short labels fit on one
line at any usable width, and a hidden button is a button that
does not exist. */}
{clusterMacrosShown.length > 0 && (
<div className="flex flex-wrap items-center gap-1 min-w-0">
{clusterMacrosShown.map((m, i) => (
<button
key={`${m.label}-${i}`}
type="button"
title={m.cmd}
onClick={() => {
// Straight out, no round trip through the input box: the
// point of the button is not to type the command for you.
clusterSend(m.cmd);
}}
className="h-8 px-2 rounded-md border border-border bg-muted/40 text-xs font-medium
hover:bg-muted active:scale-95 transition-all duration-150 truncate max-w-[10rem]"
>
{m.label}
</button>
))}
</div>
)}
</div> </div>
</div>{/* /left column */} </div>{/* /left column */}
@@ -7424,21 +7666,79 @@ export default function App() {
different widths, and which one deserves the room changes with different widths, and which one deserves the room changes with
what the operator is doing. The share is persisted (portable), what the operator is doing. The share is persisted (portable),
and a double-click puts it back to even. */} and a double-click puts it back to even. */}
{(() => {
// The panes actually configured, in order. 'none' is skipped
// rather than drawn empty: a blank quarter of the screen is not
// a layout choice anyone makes on purpose.
const panes = [mainPaneLeft, mainPaneRight, mainPane3, mainPane4]
.filter((k) => k !== 'none') as MainPaneKind[];
const grip = (vertical: boolean) => (
<span className={cn('rounded-full bg-border group-hover:bg-primary transition-colors',
vertical ? 'h-10 w-1' : 'w-10 h-1')} />
);
// Four panes, quartered: ONE vertical divider and ONE horizontal
// one, each shared by both rows/columns, so the panes always line
// up. Independent per-row splits look like a rendering fault.
if (panes.length === 4 && mainLayout4 === 'quad') {
return (
<div ref={mainSplitRef} className="grid gap-2 h-full min-h-0 p-2"
style={{
gridTemplateColumns: `${mainQuad.col}fr 6px ${100 - mainQuad.col}fr`,
gridTemplateRows: `${mainQuad.row}fr 6px ${100 - mainQuad.row}fr`,
}}>
{panes.map((k, i) => (
<div key={i} className="min-h-0 min-w-0 flex"
style={{ gridColumn: i % 2 === 0 ? 1 : 3, gridRow: i < 2 ? 1 : 3 }}>
{renderMainPane(k)}
</div>
))}
<div role="separator" aria-orientation="vertical" title={t('main.splitTip')}
style={{ gridColumn: 2, gridRow: '1 / span 3' }}
onPointerDown={(e) => startQuadDrag(e, 'col')}
onDoubleClick={() => saveQuad({ ...mainQuad, col: 50 })}
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1">
{grip(true)}
</div>
<div role="separator" aria-orientation="horizontal" title={t('main.splitTip')}
style={{ gridColumn: '1 / span 3', gridRow: 2 }}
onPointerDown={(e) => startQuadDrag(e, 'row')}
onDoubleClick={() => saveQuad({ ...mainQuad, row: 50 })}
className="group relative cursor-row-resize flex items-center justify-center -my-1 py-1">
{grip(false)}
</div>
</div>
);
}
// One pane fills the tab; two, three or four sit side by side
// with a divider between each neighbouring pair.
const n = panes.length;
const shares = n >= 2 ? (mainShares[n] ?? evenShares(n)) : [100];
const cols = shares.map((w) => `${w}fr`).join(' 6px ');
return (
<div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2" <div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
style={{ gridTemplateColumns: `${mainSplit}fr 6px ${100 - mainSplit}fr` }}> style={{ gridTemplateColumns: cols }}>
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div> {panes.map((k, i) => (
<Fragment key={i}>
{i > 0 && (
<div <div
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"
title={t('main.splitTip')} title={t('main.splitTip')}
onPointerDown={startMainSplitDrag} onPointerDown={(e) => startMainSplitDrag(e, i - 1, n)}
onDoubleClick={() => setMainSplit(50)} onDoubleClick={() => saveShares({ ...mainShares, [n]: evenShares(n) })}
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1" className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
> >
<span className="h-10 w-1 rounded-full bg-border group-hover:bg-primary transition-colors" /> {grip(true)}
</div> </div>
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div> )}
<div className="min-h-0 min-w-0 flex">{renderMainPane(k)}</div>
</Fragment>
))}
</div> </div>
);
})()}
</TabsContent> </TabsContent>
<TabsContent value="awards" className="flex-1 min-h-0 p-0"> <TabsContent value="awards" className="flex-1 min-h-0 p-0">
@@ -7611,6 +7911,11 @@ export default function App() {
onSpotClick={handleSpotClick} onSpotClick={handleSpotClick}
onClose={() => setBandMapShown(false)} onClose={() => setBandMapShown(false)}
showLotw={!!rowColors?.bandmap_lotw} showLotw={!!rowColors?.bandmap_lotw}
// The SAME setting the Band Map tab drives, deliberately: one
// answer to "does a band map show the whole band", not one per
// place a band map happens to be drawn.
fitToBand={bandMapFit}
onToggleFit={toggleBandMapFit}
keyNav keyNav
/> />
</div> </div>
@@ -7890,7 +8195,15 @@ export default function App() {
// spot already on screen showing the answer to the OLD question. // spot already on screen showing the answer to the OLD question.
setSpotStatus({}); setSpotStatus({});
}} }}
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }} onMainPaneChanged={(side, v) => {
// Applied from the CHOSEN value, never from a re-read of the DB:
// the write is async and the layout must not lag a click behind.
if (side === 'left') setMainPaneLeft(v as MainPaneKind);
else if (side === 'right') setMainPaneRight(v as MainPaneKind);
else if (side === 'p3') setMainPane3(v as MainPaneKind);
else if (side === 'p4') setMainPane4(v as MainPaneKind);
else if (side === 'layout') setMainLayout4(v === 'cols' ? 'cols' : 'quad');
}}
flexAvailable={catState.backend === 'flex'} flexAvailable={catState.backend === 'flex'}
icomAvailable={catState.backend === 'icom'} icomAvailable={catState.backend === 'icom'}
yaesuAvailable={catState.backend === 'yaesu'} yaesuAvailable={catState.backend === 'yaesu'}
@@ -179,6 +179,31 @@ export function AppearancePanel() {
<span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span> <span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span>
</label> </label>
{/* Banding is its own thing, above the QSL rules and outside them: it says
nothing about a contact, it only helps the eye keep its line across a
wide table. Turning the status colours off must not take it away. */}
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={(cfg as any).recent_zebra !== 'off'} className="mt-0.5"
onCheckedChange={(c) => save({ ...cfg, recent_zebra: c ? '' : 'off' } as any)} />
<span>{t('appr.zebra')} <span className="text-xs text-muted-foreground">{t('appr.zebraHint')}</span></span>
</label>
{(cfg as any).recent_zebra !== 'off' && (
<div className="flex items-center gap-2 pl-6">
<span className="text-xs text-muted-foreground">{t('appr.zebraColor')}</span>
<input type="color" className="size-7 rounded border border-border bg-transparent p-0 cursor-pointer"
value={/^#[0-9a-fA-F]{6}$/.test((cfg as any).recent_zebra_color ?? '') ? (cfg as any).recent_zebra_color : '#808080'}
onChange={(e) => save({ ...cfg, recent_zebra_color: e.target.value } as any)} />
{/* Empty means "follow the theme", and there has to be a way back to
it — a colour chosen under one theme is wrong under the other. */}
{(cfg as any).recent_zebra_color && (
<button type="button" className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => save({ ...cfg, recent_zebra_color: '' } as any)}>{t('appr.zebraAuto')}</button>
)}
</div>
)}
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer"> <label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} className="mt-0.5" <Checkbox checked={cfg.enabled} className="mt-0.5"
onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} /> onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
+23 -2
View File
@@ -62,6 +62,10 @@ type Preset = { key: string; name: string; field: string; dxcc: number; refs: Aw
// organizational only; matching is driven by the field/pattern/dynamic options, // organizational only; matching is driven by the field/pattern/dynamic options,
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field). // so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN']; const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
// Sentinel for "no QSO field at all". The definition stores an empty field, and
// the matcher already treats that as "nothing to scan" — this only gives the
// operator a way to ASK for it.
const NO_FIELD = '__none__';
const CONFIRM_SRC = [ const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }, { id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' }, { id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
@@ -277,6 +281,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
} }
return groups; return groups;
}, [testRows]); }, [testRows]);
const noField = !String(cur?.field ?? '').trim();
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d))); const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
const toggleIn = (key: keyof AwardDef, v: string) => { const toggleIn = (key: keyof AwardDef, v: string) => {
const arr = ((cur?.[key] as string[]) ?? []); const arr = ((cur?.[key] as string[]) ?? []);
@@ -566,11 +571,26 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<div className="border-t pt-2.5 mt-1 space-y-2.5"> <div className="border-t pt-2.5 mt-1 space-y-2.5">
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p> <p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
<Field2 label={t('awed.searchInField')}> <Field2 label={t('awed.searchInField')}>
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}> {/* NO_FIELD is a sentinel because the stored value is the
empty string and a Select cannot carry one — an empty
item value means "show the placeholder" to Radix. */}
<Select value={cur.field || NO_FIELD}
onValueChange={(v) => patch({ field: v === NO_FIELD ? '' : v })}>
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent> <SelectContent className="max-h-72">
<SelectItem value={NO_FIELD}>{t('awed.fieldNone')}</SelectItem>
{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}
</SelectContent>
</Select> </Select>
</Field2> </Field2>
{/* An award whose references live in no ADIF field at all —
WWBOTA has no column anywhere — has nothing to match on,
and every control below would be a question with no
answer. Hiding them is the point: the references are the
ones the operator assigns to a QSO by hand. */}
{noField ? (
<p className="text-[11px] text-muted-foreground pl-[128px]">{t('awed.fieldNoneHint')}</p>
) : (<>
<Field2 label={t('awed.matchBy')}> <Field2 label={t('awed.matchBy')}>
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
{['code', 'description', 'pattern'].map((m) => ( {['code', 'description', 'pattern'].map((m) => (
@@ -630,6 +650,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
); );
})} })}
</div> </div>
</>)}
</div> </div>
</TabsContent> </TabsContent>
+17 -1
View File
@@ -83,6 +83,11 @@ interface Props {
// globally from the band-map tab toolbar. // globally from the band-map tab toolbar.
hideDigital?: boolean; hideDigital?: boolean;
fitToBand?: boolean; fitToBand?: boolean;
// onToggleFit turns the zoom readout into the fit-to-band switch. Only the
// docked map passes it: the Band Map tab has the same control in its own
// toolbar, above maps that all obey it at once, and a second switch inside
// each card would be four ways to change one setting.
onToggleFit?: () => void;
// Mark stations that upload to LoTW (Settings → Appearance). // Mark stations that upload to LoTW (Settings → Appearance).
showLotw?: boolean; showLotw?: boolean;
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig // keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
@@ -263,7 +268,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// last; ties broken by closeness to the rig freq). // last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30; const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false, showLotw = false }: Props) { export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, onToggleFit, keyNav = false, showLotw = false }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// The two display options are applied ONCE here, on the whole map, so the // The two display options are applied ONCE here, on the whole map, so the
@@ -559,7 +564,18 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
title={t('bmp.zoomOut')}> title={t('bmp.zoomOut')}>
<Minus className="size-3" /> <Minus className="size-3" />
</button> </button>
{/* The readout IS the switch when the parent offers one — the header is
already tight with four maps side by side, and a separate chip would
cost width to say what this text says anyway. */}
{onToggleFit ? (
<button type="button" onClick={onToggleFit} title={t('bmp.fitTitle')}
className={cn('shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-1 rounded border transition-colors',
fitToBand ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:bg-muted')}>
{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}
</button>
) : (
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span> <span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
)}
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1} <button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30" className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomIn')}> title={t('bmp.zoomIn')}>
+52 -7
View File
@@ -55,6 +55,19 @@ const SCOPES = [
type ScopeKey = typeof SCOPES[number]['key']; type ScopeKey = typeof SCOPES[number]['key'];
const SCOPE_KEY = 'opslog.gridMapScope'; const SCOPE_KEY = 'opslog.gridMapScope';
// Chosen fill colours. Empty means "follow the theme", which is the default and
// stays the default: the tokens already track the four themes, and freezing a
// hex at first run would leave a dark-theme map painted in the light palette.
const COL_CONFIRMED_KEY = 'opslog.gridMapColorConfirmed';
const COL_WORKED_KEY = 'opslog.gridMapColorWorked';
// A colour input only accepts #rrggbb. The theme tokens ARE plain hex today, so
// this normally just passes them through — but a token that ever becomes oklch()
// or a named colour would silently drive the swatch to black, and a fallback is
// cheaper than that debugging session.
function asHex(v: string, fallback: string): string {
return /^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : fallback;
}
export function GridSquareMap({ myGrid, className }: { myGrid?: string; className?: string }) { export function GridSquareMap({ myGrid, className }: { myGrid?: string; className?: string }) {
const { t } = useI18n(); const { t } = useI18n();
@@ -69,6 +82,8 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI')); ? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap); const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
// Repaint the squares when the THEME changes, not the basemap: the fills come // Repaint the squares when the THEME changes, not the basemap: the fills come
// from theme tokens resolved at draw time, so a theme switch leaves them on // from theme tokens resolved at draw time, so a theme switch leaves them on
// the old palette until something forces a redraw. // the old palette until something forces a redraw.
@@ -105,7 +120,15 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
}).setView([25, 0], 2); }).setView([25, 0], 2);
mapRef.current = m; mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m); layerRef.current = L.layerGroup().addTo(m);
return () => { m.remove(); mapRef.current = null; layerRef.current = null; }; // Leaflet measures its container ONCE, when the map is created, and never
// looks again. Here that measurement happens while the panel is still
// laying out — so the map kept the height it had at that instant and the
// rest of the panel stayed blank underneath it, whatever the window size.
// Watching the host is the only fix that also survives a window resize, a
// split-pane drag and the tab being shown for the first time.
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
ro.observe(hostRef.current);
return () => { ro.disconnect(); m.remove(); mapRef.current = null; layerRef.current = null; };
}, []); }, []);
// The chosen basemap, shared with the Main-tab map so picking one there and // The chosen basemap, shared with the Main-tab map so picking one there and
@@ -126,8 +149,8 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
layer.clearLayers(); layer.clearLayers();
// Resolved once for the whole redraw, not per square: getComputedStyle // Resolved once for the whole redraw, not per square: getComputedStyle
// forces a style flush, and doing that a thousand times is a visible stall. // forces a style flush, and doing that a thousand times is a visible stall.
const confirmedColour = cssColour('--success', '#16a34a'); const confirmedColour = confColour || cssColour('--success', '#16a34a');
const workedColour = cssColour('--chart-1', '#2a78d6'); const workedFill = workedColour || cssColour('--chart-1', '#2a78d6');
const meColour = cssColour('--warning', '#f59e0b'); const meColour = cssColour('--warning', '#f59e0b');
for (const sq of squares ?? []) { for (const sq of squares ?? []) {
const b = gridSquareBounds(sq.grid); const b = gridSquareBounds(sq.grid);
@@ -135,7 +158,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
// One hue, two states. Confirmed is the solid, saturated one; worked is // One hue, two states. Confirmed is the solid, saturated one; worked is
// the same colour held back — so the eye reads "more" and "less" of the // the same colour held back — so the eye reads "more" and "less" of the
// same thing rather than two unrelated facts. // same thing rather than two unrelated facts.
const colour = sq.confirmed ? confirmedColour : workedColour; const colour = sq.confirmed ? confirmedColour : workedFill;
L.rectangle([[b.south, b.west], [b.north, b.east]], { L.rectangle([[b.south, b.west], [b.north, b.east]], {
color: colour, color: colour,
weight: 0.5, weight: 0.5,
@@ -159,7 +182,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
fillColor: meColour, fillOpacity: 1, fillColor: meColour, fillOpacity: 1,
}).bindTooltip(myGrid!.toUpperCase(), { sticky: true }).addTo(layer); }).bindTooltip(myGrid!.toUpperCase(), { sticky: true }).addTo(layer);
} }
}, [squares, myGrid, t, themeTick]); }, [squares, myGrid, t, themeTick, confColour, workedColour]);
const stats = useMemo(() => { const stats = useMemo(() => {
const list = squares ?? []; const list = squares ?? [];
@@ -200,6 +223,28 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
<option key={k} value={k}>{BASEMAPS[k].label}</option> <option key={k} value={k}>{BASEMAPS[k].label}</option>
))} ))}
</select> </select>
{/* Beside the basemap, because they answer the same question: what this
map looks like. Written straight through — there is no Save here. */}
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colConfirmed')}>
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
value={asHex(confColour || cssColour('--success', '#16a34a'), '#16a34a')}
onChange={(e) => { setConfColour(e.target.value); writeUiPref(COL_CONFIRMED_KEY, e.target.value); }} />
{t('gsm.confirmed')}
</label>
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colWorked')}>
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
value={asHex(workedColour || cssColour('--chart-1', '#2a78d6'), '#2a78d6')}
onChange={(e) => { setWorkedColour(e.target.value); writeUiPref(COL_WORKED_KEY, e.target.value); }} />
{t('gsm.worked')}
</label>
{(confColour || workedColour) && (
<button type="button" title={t('gsm.colReset')}
onClick={() => {
setConfColour(''); setWorkedColour('');
writeUiPref(COL_CONFIRMED_KEY, ''); writeUiPref(COL_WORKED_KEY, '');
}}
className="text-[11px] text-muted-foreground hover:text-foreground px-1"></button>
)}
<span className="flex-1" /> <span className="flex-1" />
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')} <button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50"> className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
@@ -213,12 +258,12 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
<footer className="flex items-center gap-3 px-2.5 py-1 border-t border-border bg-muted/30 shrink-0 text-[10px] text-muted-foreground"> <footer className="flex items-center gap-3 px-2.5 py-1 border-t border-border bg-muted/30 shrink-0 text-[10px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-[2px]" <span className="inline-block size-2.5 rounded-[2px]"
style={{ background: 'var(--success)', opacity: 0.75 }} /> style={{ background: confColour || 'var(--success)', opacity: 0.75 }} />
{t('gsm.confirmed')} {t('gsm.confirmed')}
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-[2px]" <span className="inline-block size-2.5 rounded-[2px]"
style={{ background: 'var(--chart-1)', opacity: 0.35 }} /> style={{ background: workedColour || 'var(--chart-1)', opacity: 0.35 }} />
{t('gsm.worked')} {t('gsm.worked')}
</span> </span>
</footer> </footer>
+1 -1
View File
@@ -720,7 +720,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
animateRows={false} animateRows={false}
suppressCellFocus suppressCellFocus
getRowId={(p) => String((p.data as any).id)} getRowId={(p) => String((p.data as any).id)}
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null)} getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null, p.node.rowIndex ?? undefined)}
/> />
</div> </div>
</div> </div>
+24 -2
View File
@@ -72,6 +72,21 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
onGoto(az); onGoto(az);
}; };
// Stop needs the same acknowledgement, for the same reason and one more: it
// is pressed when something is already wrong, and a button that stays inert
// gets hit again and again. Its own flag, so stopping does not blank a preset
// that is still lit.
const [stopFlash, setStopFlash] = useState(false);
const stopTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
const pressStop = () => {
if (!onStop) return;
setStopFlash(true);
window.clearTimeout(stopTimer.current);
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
onStop();
};
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is // 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
// almost always a typo for 36 or 306, and a rotor swinging through north on a // almost always a typo for 36 or 306, and a rotor swinging through north on a
// slip of the finger is worth one rejected keypress. // slip of the finger is worth one rejected keypress.
@@ -332,9 +347,16 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
{onStop && ( {onStop && (
<button <button
type="button" type="button"
onClick={onStop} onClick={pressStop}
title={t('rotor.stop')} title={t('rotor.stop')}
className="flex items-center justify-center gap-1.5 rounded-md border border-destructive/60 bg-destructive/15 py-1 text-xs font-bold text-destructive hover:bg-destructive/25" className={cn(
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
stopFlash
// Solid, not a tint: STOP reads the same in both languages, so
// the fill is the whole acknowledgement.
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
)}
> >
<Square className="size-3 fill-current" /> {t('rotor.stop')} <Square className="size-3 fill-current" /> {t('rotor.stop')}
</button> </button>
+103 -26
View File
@@ -83,6 +83,7 @@ import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel'; import { OperatingPanel } from '@/components/OperatingPanel';
import { AppearancePanel } from '@/components/AppearancePanel'; import { AppearancePanel } from '@/components/AppearancePanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel'; import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros';
type LookupSettings = LookupSettingsForm; type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm; type StationSettings = StationSettingsForm;
@@ -167,7 +168,7 @@ interface Props {
initialSection?: string; initialSection?: string;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update onMainPaneChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; // live Main-view layout update
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane
@@ -1028,15 +1029,16 @@ function RelayAutoPanel() {
); );
} }
// MainViewPanes lets the operator choose what the Main tab's left and right // MainViewPanes lets the operator choose what the Main tab shows, in up to four
// panes show, independently: the great-circle map, the locator street map, the // panes. The first two are the view; the third and fourth can be left empty, and
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref, // an empty one is not drawn at all. Per-profile (stored via SetUIPref, which is
// which is profile-prefixed). Self-contained so it owns its async-loaded state. // profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes']; const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes'];
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) { const PANE_NONE = 'none';
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
const { t } = useI18n(); const { t } = useI18n();
const [left, setLeft] = useState('map1'); const [panes, setPanes] = useState<Record<string, string>>({ left: 'map1', right: 'map2', p3: PANE_NONE, p4: PANE_NONE });
const [right, setRight] = useState('map2'); const [layout, setLayout] = useState('quad');
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z. // Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
const options = [ const options = [
...MAIN_PANE_VALUES, ...MAIN_PANE_VALUES,
@@ -1045,42 +1047,77 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable
...(yaesuAvailable ? ['yaesu'] : []), ...(yaesuAvailable ? ['yaesu'] : []),
].map((value) => ({ value, label: t(`settings.pane.${value}`) })) ].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
const KEYS: Record<string, string> = { left: 'mainPaneLeft', right: 'mainPaneRight', p3: 'mainPane3', p4: 'mainPane4' };
useEffect(() => { useEffect(() => {
const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || MAIN_PANE_VALUES.includes(v); const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || MAIN_PANE_VALUES.includes(v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')]) Promise.all([
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); }); ...Object.values(KEYS).map((k) => GetUIPref(k).catch(() => '')),
GetUIPref('mainPaneLayout').catch(() => ''),
]).then(([l, r, p3, p4, lay]) => {
setPanes({
left: valid(l) ? l : 'map1',
right: valid(r) ? r : 'map2',
p3: valid(p3) ? p3 : PANE_NONE,
p4: valid(p4) ? p4 : PANE_NONE,
});
if (lay === 'cols' || lay === 'quad') setLayout(lay);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const pick = (side: 'left' | 'right', v: string) => { const pick = (side: string, v: string) => {
if (side === 'left') setLeft(v); else setRight(v); setPanes((p) => ({ ...p, [side]: v }));
// Persist (per-profile) AND tell the parent the new value directly, so the // Persist (per-profile) AND tell the parent the new value directly, so the
// Main view updates from the chosen value — never a stale DB re-read. // Main view updates from the chosen value — never a stale DB re-read.
SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {}); SetUIPref(KEYS[side], v).catch(() => {});
onChanged?.(side, v); onChanged?.(side as any, v);
}; };
const pickLayout = (v: string) => {
setLayout(v);
SetUIPref('mainPaneLayout', v).catch(() => {});
onChanged?.('layout', v);
};
const filled = ['left', 'right', 'p3', 'p4'].filter((k) => panes[k] !== PANE_NONE).length;
const SIDES = [
{ key: 'left', label: t('settings.leftPane'), canBeEmpty: false },
{ key: 'right', label: t('settings.rightPane'), canBeEmpty: false },
{ key: 'p3', label: t('settings.thirdPane'), canBeEmpty: true },
{ key: 'p4', label: t('settings.fourthPane'), canBeEmpty: true },
];
return ( return (
<div className="border-t border-border/60 pt-4 space-y-2"> <div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4> <h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p> <p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
<div className="grid grid-cols-2 gap-3 max-w-xl"> <div className="grid grid-cols-2 gap-3 max-w-xl">
<label className="flex flex-col gap-1 text-xs"> {SIDES.map((sd) => (
<span className="text-muted-foreground">{t('settings.leftPane')}</span> <label key={sd.key} className="flex flex-col gap-1 text-xs">
<Select value={left} onValueChange={(v) => pick('left', v)}> <span className="text-muted-foreground">{sd.label}</span>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger> <Select value={panes[sd.key]} onValueChange={(v) => pick(sd.key, v)}>
<SelectContent>
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
<Select value={right} onValueChange={(v) => pick('right', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{/* Only the third and fourth may be empty: the first two ARE the
Main view, and a tab with nothing in it is not a layout. */}
{sd.canBeEmpty && <SelectItem value={PANE_NONE}>{t('settings.paneNone')}</SelectItem>}
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)} {options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
</label> </label>
))}
</div> </div>
{/* Only asked when it can matter. Three panes are columns and nothing
else; four are the only case with two honest answers. */}
{filled === 4 && (
<div className="flex items-center gap-2 pt-1">
<span className="text-xs text-muted-foreground">{t('settings.paneLayout')}</span>
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
{[{ v: 'quad', l: t('settings.paneQuad') }, { v: 'cols', l: t('settings.paneCols') }].map((o) => (
<button key={o.v} type="button" onClick={() => pickLayout(o.v)}
className={cn('px-2.5 py-1', layout === o.v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
</div>
)}
</div> </div>
); );
} }
@@ -1530,6 +1567,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1'); const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1'); const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
// function by the PANELS map, so it must stay hooks-free.
const [clusterMacros, setClusterMacros] = useState<ClusterMacro[]>(loadClusterMacros);
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1'); const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1'); const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state. // Password-encryption (secret vault) state.
@@ -4677,6 +4717,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
function ClusterPanel() { function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
// Written on every keystroke. This panel has no Save button, and a pair of
// text boxes whose contents only take effect on some other button is how
// work gets lost.
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
const next = clusterMacros.map((m, j) => (j === i ? { ...m, ...patch } : m));
setClusterMacros(next);
saveClusterMacros(next);
};
return ( return (
<> <>
<SectionHeader <SectionHeader
@@ -4769,6 +4817,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{t('clu.autoConnect')} {t('clu.autoConnect')}
</label> </label>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-2">
<div>
<span className="text-sm font-medium">{t('clu.macros')}</span>
<p className="text-xs text-muted-foreground">{t('clu.macrosHint')}</p>
</div>
{/* Two columns of six: twelve rows stacked would push everything else
in this panel off the screen. */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
{clusterMacros.map((m, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
<Input
className="h-8 w-28 shrink-0 text-xs"
placeholder={t('clu.macroLabel')}
value={m.label}
maxLength={24}
onChange={(e) => setMacro(i, { label: e.target.value })}
/>
<Input
className="h-8 flex-1 min-w-0 font-mono text-xs"
placeholder={t('clu.macroCmd')}
value={m.cmd}
maxLength={120}
onChange={(e) => setMacro(i, { cmd: e.target.value })}
/>
</div>
))}
</div>
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('clu.freeNodes')} <span className="font-mono">dxc.k0xm.net:7300</span>,{' '} {t('clu.freeNodes')} <span className="font-mono">dxc.k0xm.net:7300</span>,{' '}
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '} <span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
@@ -10,6 +10,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { QSO_BOX_FIELDS, QSO_FIELD_LABELS } from './qslTypes';
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes'; import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
import type { CardSelection } from './CardPreview'; import type { CardSelection } from './CardPreview';
import { StylePresetPicker, NumberField } from './StylePresetPicker'; import { StylePresetPicker, NumberField } from './StylePresetPicker';
@@ -199,6 +200,26 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''} <Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
onChange={(ev) => onPatchBox({ title: ev.target.value })} /> onChange={(ev) => onPatchBox({ title: ev.target.value })} />
</div> </div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Columns</Label>
{/* Rebuilt from the canonical order every time rather than
appended to: a box whose columns come out in the order they
were ticked reads as a bug, and the widths are tuned for this
order. */}
<div className="grid grid-cols-2 gap-x-2">
{QSO_BOX_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={box.fields.includes(f)}
onCheckedChange={(v) => onPatchBox({
fields: QSO_BOX_FIELDS.filter((k) => (k === f ? v === true : box.fields.includes(k))),
})}
/>
{QSO_FIELD_LABELS[f] ?? f}
</label>
))}
</div>
</div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Footer</Label> <Label className="text-xs text-muted-foreground">Footer</Label>
<Input className="h-7 w-44 font-mono text-xs" value={box.footer} <Input className="h-7 w-44 font-mono text-xs" value={box.footer}
+6
View File
@@ -165,6 +165,12 @@ export interface QSLPresetInfo {
} }
// Human labels for the QSO box fields the renderer knows. // Human labels for the QSO box fields the renderer knows.
// QSO_BOX_FIELDS is the canonical COLUMN ORDER of the confirmation box, and
// the whole list the designer offers. The renderer's column widths are tuned
// for it, so a box is always rebuilt from this order rather than from the
// order the operator happened to tick the boxes in.
export const QSO_BOX_FIELDS = ['qso_date', 'time_on', 'band', 'freq', 'mode', 'submode', 'rst_sent'] as const;
export const QSO_FIELD_LABELS: Record<string, string> = { export const QSO_FIELD_LABELS: Record<string, string> = {
qso_date: 'Date', qso_date: 'Date',
time_on: 'UTC', time_on: 'UTC',
+57
View File
@@ -0,0 +1,57 @@
// Cluster command macros — a named button for a command you would otherwise
// retype.
//
// Twelve slots, fixed. A list you can grow needs add/remove/reorder controls and
// a decision about what an empty row means; twelve boxes you fill in need
// neither, and nobody has thirteen cluster commands they use daily. A slot with
// no command is simply not drawn, so the toolbar is as long as the operator made
// it and no longer.
//
// Stored through writeUiPref like every other portable preference, so the
// buttons travel with data/ rather than living in one browser profile.
import { writeUiPref } from '@/lib/uiPref';
export type ClusterMacro = {
label: string; // what the button says
cmd: string; // what is sent to the master server
};
export const CLUSTER_MACRO_COUNT = 12;
export const clusterMacrosKey = 'opslog.clusterMacros';
export const emptyClusterMacros = (): ClusterMacro[] =>
Array.from({ length: CLUSTER_MACRO_COUNT }, () => ({ label: '', cmd: '' }));
// loadClusterMacros always returns exactly CLUSTER_MACRO_COUNT entries, whatever
// was stored: a saved list from a build with fewer slots must not leave the
// editor rendering undefined rows.
export function loadClusterMacros(): ClusterMacro[] {
const out = emptyClusterMacros();
try {
const raw = localStorage.getItem(clusterMacrosKey);
if (!raw) return out;
const v = JSON.parse(raw);
if (!Array.isArray(v)) return out;
for (let i = 0; i < CLUSTER_MACRO_COUNT && i < v.length; i++) {
out[i] = {
label: String(v[i]?.label ?? '').slice(0, 24),
cmd: String(v[i]?.cmd ?? '').slice(0, 120),
};
}
} catch { /* a corrupt preference is not worth failing the panel over */ }
return out;
}
export function saveClusterMacros(macros: ClusterMacro[]): void {
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
}
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
// what decides: a slot with a label and no command is a button that lies, and a
// command with no label still has something to show — its own text.
export function visibleClusterMacros(macros: ClusterMacro[]): ClusterMacro[] {
return macros
.filter((m) => m.cmd.trim() !== '')
.map((m) => ({ label: m.label.trim() || m.cmd.trim(), cmd: m.cmd.trim() }));
}
File diff suppressed because one or more lines are too long
+36 -7
View File
@@ -16,9 +16,16 @@ export type RowColorSettings = {
style?: 'bar' | 'tint' | 'both'; style?: 'bar' | 'tint' | 'both';
intensity?: number; intensity?: number;
bandmap_lotw?: boolean; bandmap_lotw?: boolean;
recent_zebra?: string; // '' = alternate (default), 'off' = every row alike
recent_zebra_color?: string; // '' = follow the theme
rules: RowColorRule[]; rules: RowColorRule[];
}; };
// The banding the grid theme applies on its own. Repeated here because turning
// the option OFF means painting every row explicitly — the theme would
// otherwise keep striping underneath whatever we do per row.
const ZEBRA_DEFAULT = 'color-mix(in srgb, var(--muted) 40%, var(--card))';
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const; export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an // The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
@@ -76,21 +83,43 @@ export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule
// A log where nearly every contact has SOME QSL state ends up with every row // A log where nearly every contact has SOME QSL state ends up with every row
// painted, and colour that is always present stops being information. The // painted, and colour that is always present stops being information. The
// default is a stripe down the left edge; a tint is offered at a chosen strength. // default is a stripe down the left edge; a tint is offered at a chosen strength.
export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined { // rowIndex is optional: only the Recent QSOs grid bands its rows, and the other
if (!cfg?.enabled) return undefined; // callers have nothing to say about odd and even.
export function rowStyleFor(q: any, cfg: RowColorSettings | null, rowIndex?: number): Record<string, string> | undefined {
const out: Record<string, string> = {};
// Banding first, and INDEPENDENT of cfg.enabled: it is legibility, not QSL
// status, and an operator who wants plain rows should not have to turn the
// status colours off to get them.
if (typeof rowIndex === 'number') {
if (cfg?.recent_zebra === 'off') {
out.backgroundColor = 'var(--card)';
} else if (rowIndex % 2 === 1) {
out.backgroundColor = cfg?.recent_zebra_color || ZEBRA_DEFAULT;
}
}
const empty = () => (Object.keys(out).length ? out : undefined);
if (!cfg?.enabled) return empty();
const rule = matchRowRule(q, cfg); const rule = matchRowRule(q, cfg);
if (!rule?.color) return undefined; if (!rule?.color) return empty();
const style = cfg.style ?? 'bar'; const style = cfg.style ?? 'bar';
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12)); const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
const out: Record<string, string> = {};
if (style === 'tint' || style === 'both') { if (style === 'tint' || style === 'both') {
// Overrides the banding on purpose: the QSL colour is information, the
// banding is only there to help the eye keep its line.
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`; out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
} }
if (style === 'bar' || style === 'both') { if (style === 'bar' || style === 'both') {
// inset shadow rather than a border: a border would shift the cells three // A background gradient, not a border and not a shadow.
// pixels on coloured rows only, and the columns would stop lining up. //
out.boxShadow = `inset 3px 0 0 ${rule.color}`; // A border would shift the cells three pixels on coloured rows only, and
// the columns would stop lining up. An inset box-shadow was the first fix
// for that and drew the stripe correctly — but on the grid's transformed,
// absolutely-positioned rows it also bled a hairline of the same colour
// along the row edges, so every coloured row got a horizontal rule it was
// never meant to have. A gradient paints inside the box and nothing else:
// three pixels of colour, then transparent, and no edge to bleed from.
out.backgroundImage = `linear-gradient(to right, ${rule.color} 0, ${rule.color} 3px, transparent 3px)`;
} }
return out; return out;
} }
+7 -1
View File
@@ -41,13 +41,19 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter', 'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked', 'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab 'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) 'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
'opslog.mainQuad', // Main tab: the 2x2 cross, as {col,row} percentages
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots 'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode 'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
'opslog.bandMapWidth', // docked band map: column width (px) 'opslog.bandMapWidth', // docked band map: column width (px)
'opslog.bandMapTabWidth', // Band map tab: shared card width (px) 'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map 'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map 'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
'opslog.clusterMacros', // cluster console: the twelve named command buttons
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme)
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here. // NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER // They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them // PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.26.0'; export const APP_VERSION = '0.26.1';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+4
View File
@@ -3301,6 +3301,8 @@ export namespace main {
style: string; style: string;
intensity: number; intensity: number;
bandmap_lotw: boolean; bandmap_lotw: boolean;
recent_zebra?: string;
recent_zebra_color?: string;
configured: boolean; configured: boolean;
rules: RowColorRule[]; rules: RowColorRule[];
@@ -3314,6 +3316,8 @@ export namespace main {
this.style = source["style"]; this.style = source["style"];
this.intensity = source["intensity"]; this.intensity = source["intensity"];
this.bandmap_lotw = source["bandmap_lotw"]; this.bandmap_lotw = source["bandmap_lotw"];
this.recent_zebra = source["recent_zebra"];
this.recent_zebra_color = source["recent_zebra_color"];
this.configured = source["configured"]; this.configured = source["configured"];
this.rules = this.convertValues(source["rules"], RowColorRule); this.rules = this.convertValues(source["rules"], RowColorRule);
} }
+23
View File
@@ -684,3 +684,26 @@ func TestCatalogForcesBuiltin(t *testing.T) {
t.Fatal("empty catalog") t.Fatal("empty catalog")
} }
} }
// An award with NO QSO field at all counts only what the operator assigned by
// hand. WWBOTA is the case: bunker references have no ADIF field anywhere, so
// the definition names no field and the matcher must scan nothing rather than
// fall back on some default. The manual override still applies — it is the only
// thing that can feed such an award.
func TestComputeNoFieldManualOnly(t *testing.T) {
def := Def{Code: "WWBOTA", Type: TypeReference, Field: "", Dynamic: true, Valid: true}
qsos := []qso.QSO{
// Assigned by hand → counts.
{Callsign: "F4ABC", Band: "40m", Extras: map[string]string{ManualRefsKey: "WWBOTA@B/F-0123"}},
// Nothing assigned. The other fields are deliberately full: none of them
// may be scanned just because no field was named.
{Callsign: "F4DEF", Band: "20m", POTARef: "FF-0001", QTH: "B/F-0999", Comment: "B/F-0888"},
}
r := Compute([]Def{def}, qsos, nil, nil)[0]
if r.Worked != 1 {
t.Errorf("WWBOTA worked = %d, want 1 (%v)", r.Worked, refCodes(r))
}
if got := refCodes(r); len(got) != 1 || got[0] != "B/F-0123" {
t.Errorf("WWBOTA refs = %v, want [B/F-0123]", got)
}
}
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -184,7 +184,29 @@ func (m *Manager) stopLocked() {
iv.Interrupt() iv.Interrupt()
} }
if done != nil { if done != nil {
<-done // Bounded, and loud when it expires.
//
// This used to be a bare <-done. A poll goroutine wedged in a serial read
// then held every caller for ever: each restart attempt blocked inside
// Start before it could even try to connect, so the rig stayed dead and
// NOTHING was written to the log — the operator saw a CAT link that would
// not come back and no reason anywhere. Shutdown blocked on the same wait.
//
// Ten seconds is far longer than any honest disconnect. Past that the old
// poller is not coming back, and carrying on without it — noisily — beats
// freezing the application around it. The abandoned goroutine still holds
// its port, which is exactly what the log line has to say, because the
// next connect will fail because of it.
select {
case <-done:
case <-time.After(10 * time.Second):
name := "cat"
if b != nil {
name = b.Name()
}
debugLog.Printf("%s: poll loop did not stop within 10s — abandoning it; "+
"its port stays open, so the next connect may fail until OpsLog is restarted", name)
}
} }
} }
+48 -2
View File
@@ -223,6 +223,15 @@ type Server struct {
// which fills the whole rotating log with the same line and buries the // which fills the whole rotating log with the same line and buries the
// evidence of anything else. // evidence of anything else.
badPkts int badPkts int
// drops counts events discarded because the consumer could not keep up —
// see the select at the foot of handle(). They used to vanish in silence,
// which made a missing decode indistinguishable from one the sender never
// broadcast: an operator comparing the panel against JTDX side by side was
// the only detector we had. A period's decodes arrive as one burst, so this
// is exactly when it happens.
drops int
lastDropL time.Time
} }
// maxBadPktDumps is how many unparseable datagrams a listener describes in full // maxBadPktDumps is how many unparseable datagrams a listener describes in full
@@ -663,10 +672,43 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
select { select {
case s.out <- ev: case s.out <- ev:
default: default:
// Drop on backpressure rather than block the read loop. // Drop on backpressure rather than block the read loop — but say so.
s.noteDrop(ev)
} }
} }
// noteDrop reports events lost to a full queue, first one immediately and then
// at most one line a minute with the running total.
//
// Rate-limited because the condition is self-sustaining: a consumer that fell
// behind on one period is behind for the next, and a line per lost decode would
// bury the rest of the log under the symptom. The event's own description goes
// in, because "a decode was lost" and "a Status was lost" are different faults.
func (s *Server) noteDrop(ev Event) {
what := "event"
switch {
case ev.DecodeCall != "":
what = "decode from " + ev.DecodeCall
case ev.LoggedADIF != "":
what = "logged QSO"
case ev.TxMessage != "" || ev.DECall != "":
what = "transmit status"
}
s.mu.Lock()
s.drops++
n := s.drops
quiet := n > 1 && time.Since(s.lastDropL) < time.Minute
if !quiet {
s.lastDropL = time.Now()
}
s.mu.Unlock()
if quiet {
return
}
applog.Printf("udp: [%s] queue full — dropped %s (%d lost this session); "+
"the decodes panel is missing what the sender broadcast", s.cfg.Name, what, n)
}
func (s *Server) close() { func (s *Server) close() {
s.mu.Lock() s.mu.Lock()
if s.stopped { if s.stopped {
@@ -735,7 +777,11 @@ type Manager struct {
func NewManager(repo *Repo) *Manager { func NewManager(repo *Repo) *Manager {
return &Manager{ return &Manager{
repo: repo, repo: repo,
out: make(chan Event, 64), // 256, not 64: a period delivers twenty-odd decodes in one burst while
// Status keeps arriving, and the consumer does real work per event. The
// depth is headroom for that burst — the drop counter above is what says
// whether it was enough.
out: make(chan Event, 256),
inbound: map[int64]*Server{}, inbound: map[int64]*Server{},
} }
} }
+5 -1
View File
@@ -733,7 +733,11 @@ func placeQSOBox(profile ProfileInfo, zone pxRect, occupied []pxRect) QSOBox {
Enabled: true, W: 760, H: 220, Enabled: true, W: 760, H: 220,
BG: "#ffffff", BGOpacity: 0.88, Radius: 12, BG: "#ffffff", BGOpacity: 0.88, Radius: 12,
Title: "Confirming QSO with {qso.callsign}", Title: "Confirming QSO with {qso.callsign}",
Fields: []string{"qso_date", "time_on", "band", "mode", "rst_sent"}, // The frequency belongs on a card: "20m" says which band the QSL is for,
// the megahertz say where the contact actually was — and a card is often
// the only record the OTHER station keeps. Removable in the designer for
// anyone who wants the band alone.
Fields: []string{"qso_date", "time_on", "band", "freq", "mode", "rst_sent"},
// PSE/TNX stamp (auto: TNX if a QSL was received, else PSE QSL) leads the // PSE/TNX stamp (auto: TNX if a QSL was received, else PSE QSL) leads the
// message. {qso.pse_tnx} is a normal token — it can be moved to its own // message. {qso.pse_tnx} is a normal token — it can be moved to its own
// element anywhere on the card in the designer. // element anywhere on the card in the designer.
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.26.0" appVersion = "0.26.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.