Compare commits

..
12 Commits
Author SHA1 Message Date
rouggy 7eff19f6e2 chore: release v0.24.4 2026-08-10 21:18:53 +02:00
rouggy e0dfa53f54 chore(startup): measure the part of a slow launch the log could not see
An operator reporting "nothing happens for two or three seconds after I click"
was impossible to answer from opslog.log, because the first line applog writes
already sits inside startup(). Everything spent loading the binary and creating
the WebView2 environment happened before the log begins.

In one of the reported logs, startup() to window-visible is 251 ms end to end -
so whatever the wait is, it is not OpsLog initialising. It just could not be
proven, only asserted.

processStart is stamped on the first instruction of main. Two lines now bracket
the launch: how long was spent before startup() ran at all, and click-to-window,
which is the only number that matches what the operator experiences.
2026-08-10 20:30:08 +02:00
rouggy 4f87cedc2c feat(cluster): show the grid, and flag a new one
Finishes the half of this that was already computing on the backend and reaching
nobody. A Grid column in the Geo group, NEW GRID as a badge in Status, a filled
cell in the marker's own colour, and a filter chip.

new_grid joins lib/spotMarkers rather than getting colours of its own, so the
badge, the cell fill and the chip cannot drift apart - and the per-marker colour
setting will drive it with the rest from one table. Magenta: the last hue in the
categorical set not already spoken for, and one that does not read as a status,
because a new square is never urgent the way a new entity is.

The band map leaves it to the cluster, as it already leaves the prefix. The pill
is 22 px tall and its accent strip stops being readable past three segments.

A row carrying a new grid is no longer "dull", or the dimming would grey out the
one thing worth looking at.

The column is off by default, like the other Geo columns: it is only ever filled
for stations this receiver decoded over the UDP link, so for an operator who
does not run digital it would be a permanently empty column.
2026-08-10 20:05:34 +02:00
rouggy d273d21f1a fix(cluster): muting a worked spot must not brighten it
"No colour on worked" blanked the spot status as well as the worked-call flag,
on the theory that a spot bringing no novelty should stop painting entirely.

But the status is exactly what the cluster list reads to DIM a row. Blanking it
made isDull() fall through its "unresolved, never dim" guard, so every quiet grey
row came back at full brightness. The option meant to calm the list was the one
making it shout. It also forced a `muted` flag to exist purely so tooltips could
say that an empty status did not, this time, mean "entity not resolved".

It now removes the blue already-worked mark and nothing else, which is all it
ever needed to do: a worked entity already renders with no colour of its own and
is already dimmed. The flag, its two tooltip strings and the special-cased band
map styling all go with it.
2026-08-10 19:55:02 +02:00
rouggy bde136b98b fix(pgxl): match a reply to the command that asked for it
The amplifier PUSHES status frames ("S0|state=…") on the same socket it answers
commands on, and it pushes them constantly once in OPERATE - power, SWR and
temperature all move while transmitting.

command() read exactly one line and took whatever arrived first as its answer.
One pushed frame therefore put the stream permanently one reply behind: every
later command read the PREVIOUS command's answer, and eventually one waited out
the 3 s deadline, failed, and dropped the connection. Hence a fresh TCP
connection and a fresh authentication every few seconds in the log.

The stall in transmit is the same defect seen from the other side: command()
holds the connection mutex across that whole dead wait, so SetOperate and every
other control queued behind up to three seconds of nothing.

It only appeared remotely and in OPERATE because that is when there is anything
to push - on a LAN with an idle amplifier the race hardly ever opens. Which is
why it did not do this yesterday.

readReplyLocked now reads until the R<id>| that belongs to the command, feeding
every frame it passes to parse() on the way - a pushed status is fresher than
the one we were about to ask for, so nothing is wasted.

authLocked used the same one-line read and worked around this by re-sending the
whole handshake, which is visible in the log as "auth reply=S0|state=IDLE
(try 1)" followed by a second attempt. It goes through the same reader now.
2026-08-10 19:45:54 +02:00
rouggy ae7472a67d feat(station): MY_IOTA in the station profile
IOTA is an official ADIF field, and almost everything for it was already here:
the qso table has carried my_iota since the first migration, the insert and scan
handle it, and both ADIF import and export write it. The one missing link was
the station profile, so the reference had to be typed on every contact of an
island activation, or added afterwards in a bulk edit.

Migration 0025 adds the column; it joins my_sota_ref and my_pota_ref in the
profile, in the Station Information panel, and in the same stamp-if-empty block
that fills the other My* fields on a logged QSO.

Uppercased and trimmed on save. ADIF spells it EU-005, and an operator typing
"eu-005" would otherwise put a reference no award matcher recognises on every
QSO of an activation - the kind of mistake you only find months later.
2026-08-10 19:28:07 +02:00
rouggy 0d48dbfd17 fix(cluster): drop the worked index when a QSO is logged
AddQSO emitted qso:logged but never invalidated the cluster status snapshot.
The frontend did its part - it re-queried every visible spot two seconds later -
and ClusterSpotStatuses answered out of a snapshot built before the contact, so
it returned exactly the same "new band" as before. For ever, until an import, an
edit or a profile switch happened to invalidate it for another reason.

Reported twice: an E51 and then a ZD7 that stayed yellow on the band map with
the QSO plainly in the log. The first report was answered by fixing the
visibility gate, which was a real bug of its own and hid this one.

Confirmed against the operator's MySQL logbook rather than guessed: ZD7BG has
dxcc=250 on all eleven QSOs and 0 of 29579 rows lack a DXCC number, which ruled
out the missing-entity-number theory and pointed here.

Deliberately not invalidateAwardStats(): that also drops the award matrices,
which are expensive on a large log, and a contest run would pay for it once per
QSO. This index is a few DISTINCT scans and it is what the spot colours read.

Also fixes the new worked-grid query, which asked for a column named
"gridsquare". The column is "grid" - gridsquare_ext is a different one - so NEW
GRID could never have worked on either backend. Verified against the real
database: 12516 distinct grid|mode pairs.
2026-08-10 19:18:49 +02:00
rouggy d6ed9f03eb fix(cluster): strip the skimmer suffix before resolving the spotter's continent
RBN spotters report as "VU2OY-#" and cluster nodes as "DL1ABC-2". That string
went straight into the DXCC prefix matcher, which saw an unknown callsign and
gave up, so every spot came back with an empty continent.

The filter then matched nothing at all - and worse, silently: unresolved spots
are deliberately never dropped, because the status arrives a moment after the
row and filtering meanwhile makes the list flicker. So selecting AS left the
Europeans and the Americans exactly where they were, with nothing to say why.

A real callsign never contains a hyphen, so cutting at the first one is safe.
2026-08-10 19:06:52 +02:00
rouggy c95a1137fc refactor(cluster): one rule for the filter panel
The panel had grown two shapes for the same kind of choice. Some filters were
checkboxes, some were chips, and New counties only was a checkbox duplicating a
chip - which I added, and which was the worst of it: a control that exists twice
is not more discoverable, it is one control the operator has to recognise twice.

The rule now:

  SWITCH  a behaviour that is on or off, and narrows nothing by a property of
          the station: hide worked, group duplicates, the two display options,
          LoTW users only.
  CHIPS   pick any number from a set; none picked means all. Status, mode,
          spotter continent. Selected is solid, unselected is the same chip
          faded, so the palette keeps teaching the colour code while switched
          off.

Nothing appears in both shapes. The duplicate county checkbox is gone.

Spotter continent became a chip row: seven two-letter codes fit on two lines,
they read as a set the way Status and Mode do, and several can be picked at
once - which "EU or NA" needs and a dropdown cannot express.

Both Lock buttons now sit in the heading of the section they lock, instead of
floating between sections, and every multi-select section clears the same way
through the same heading slot. One section helper and one chip helper, so the
next filter cannot drift.

The panel was also entirely hardcoded English - Search call, Hide worked, Bands,
Status - against the project's own bilingual rule. All of it goes through t()
now, both locales.
2026-08-10 19:00:09 +02:00
rouggy d41352a3a5 feat(cluster): LoTW badge and filter, spotter-continent filter
The L badge is a single letter, not a word: the call column is 120 px and holds
a callsign. It keeps the muted blue of a confirmation and never a status colour,
because whether a station uploads to LoTW says nothing about whether the spot is
worth chasing - those are different questions and must not share a palette.

The spotter's continent is not the DX's. It asks whether anyone near you is
hearing the band at all, which is why it earns its own control rather than
reusing the existing Continent column. The spotter callsign now travels with the
status query so the backend can resolve it against the one DXCC prefix table,
instead of a second continent rule appearing in the frontend.

Both AND with the status chips rather than joining their OR: "a new band, and
from Europe" is the question being asked. An unresolved spot is never dropped by
them - the status arrives a moment after the row, and filtering meanwhile made
the list flicker.

New counties only is the SAME state as the NEW COUNTY chip, reached a second
way, not a second filter. County chasing is a mode you switch into, and hunting
for one chip among eight is not how you switch into it.
2026-08-10 18:51:56 +02:00
rouggy 9b2115be8f feat(cluster): grids from the UDP decodes, plus spotter continent and LoTW
Backend groundwork; the columns and filters that consume it come next.

GRIDS. A CQ is the one WSJT-X message that carries a locator, and wsjtSender was
throwing that token away. It is now returned, validated as a real field+square,
and remembered per callsign. This is the ONLY grid source available: a DX-cluster
line carries the spotter's grid at best and never the DX's, and a per-callsign
QRZ lookup under an RBN firehose is not a trade worth making. So grids are known
for the stations this receiver decoded - which is exactly the FT8/FT4 watering
hole an operator is looking at while grid chasing.

RR73 is why the grid is validated rather than pattern-matched. R is inside A-R
and 73 inside 00-99, so a sign-off satisfies the Maidenhead shape exactly and
would have planted a grid that does not exist into the index, silently.

NEW GRID keys on "GRID|MODE" with the mode put through the same normMode as
everything else, so the "group digital modes" option decides whether a grid
worked on FT8 is still new on FT4 - one rule, no branch. Grids are truncated to
four characters: a log holds a mix of JN36 and JN36QU, and without that the same
square is new forever, once per subsquare.

On cost, which was the condition: one more DISTINCT scan when the status
snapshot is rebuilt, then map lookups per spot. The same shape as the county and
POTA sets it sits beside, and the snapshot exists precisely so a spot batch never
touches the logbook.

Spotter continent and the LoTW flag come from tables already in memory - the
DXCC prefix table and ARRL's user list - so they cost a lookup each. The spotter
continent answers a different question from the DX's: whether anyone near you is
hearing this at all.
2026-08-10 16:52:36 +02:00
rouggy 0a8c1ac45f chore: open 0.24.4
Empty block at the top so the next change has somewhere to go. 0.24.3 keeps its
four entries; the release script stamps the version constants.
2026-08-10 13:57:32 +02:00
20 changed files with 580 additions and 242 deletions
+126 -1
View File
@@ -541,6 +541,8 @@ type StationSettings struct {
MyCountry string `json:"my_country"`
MySOTARef string `json:"my_sota_ref"`
MyPOTARef string `json:"my_pota_ref"`
// MyIOTA is the island the station operates FROM (ADIF MY_IOTA), e.g. EU-005.
MyIOTA string `json:"my_iota"`
}
// LookupSettings is the JSON shape exchanged with the frontend.
@@ -595,6 +597,18 @@ type App struct {
// or when a setting that shapes the maps flips.
clusterStatusIdx *clusterStatusCache
clusterStatusMu sync.Mutex
// decodeGrids maps a callsign to the 4-character grid it announced in a CQ
// heard over the WSJT-X UDP link. It is the ONLY source of grids we have for
// a spot: a DX-cluster line carries the spotter's grid at best, never the
// DX's, and a per-callsign QRZ lookup under an RBN firehose is out of the
// question. So grids are known for the stations this station's own receiver
// decoded — which is exactly the FT8/FT4 watering hole an operator is looking
// at when grid chasing.
//
// In memory only, and bounded: it is a session-local view of who is on the
// air now, not a database.
decodeGrids map[string]string
decodeGridsMu sync.RWMutex
// Self-spot throttle: when and on what frequency we last announced ourselves.
// Held in memory only — a restart legitimately re-announces the station.
selfSpotMu sync.Mutex
@@ -950,7 +964,12 @@ func (a *App) startup(ctx context.Context) {
// impossible: the copy someone is actually running is not always the one they
// think they installed, and the version shown in the UI is the only clue.
exe, _ := os.Executable()
applog.Printf("startup: OpsLog %s — %s", appVersion, exe)
// Everything before this line is outside our control: Windows mapping a 30 MB
// binary, an antivirus reading all of it, and Wails creating the WebView2
// environment. Logging it separates "OpsLog is slow" from "starting OpsLog is
// slow", which are two different problems with two different fixes.
applog.Printf("startup: OpsLog %s — %s (%.0f ms before startup: exe load + WebView2)",
appVersion, exe, float64(time.Since(processStart).Microseconds())/1000)
applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
@@ -1391,6 +1410,10 @@ func (a *App) startup(ctx context.Context) {
func (a *App) domReady(ctx context.Context) {
a.restoreWindowPosition()
wruntime.WindowShow(ctx)
// The one number that matches what the operator actually experiences: click
// to window. Anything else measures a part of it.
applog.Printf("startup: window visible %.0f ms after launch",
float64(time.Since(processStart).Microseconds())/1000)
}
// StartupStatus returns a diagnostic snapshot for the frontend.
@@ -2660,6 +2683,23 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
// and the audio was discarded (recordings silently stopped working). The
// snapshot is an in-memory copy; the heavy file encode still runs async.
a.saveQSORecording(&q)
// Drop the cluster worked-index snapshot BEFORE announcing, so the refresh
// the frontend fires on qso:logged rebuilds it and sees this QSO.
//
// Without this the spot colours never moved after a contact. Logging emitted
// the event, the frontend dutifully re-queried every visible spot two
// seconds later, and ClusterSpotStatuses answered out of a snapshot built
// before the QSO — the same "new band" as before, for ever. Reported on an
// E51 and again on a ZD7 that stayed yellow on the band map with the QSO
// plainly in the log.
//
// Deliberately NOT invalidateAwardStats(): that also drops the award
// matrices, which are expensive to rebuild on a large log, and a contest run
// would pay for it once per QSO. This index is a handful of DISTINCT scans
// and it is what the spot colours actually read.
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
// form clears immediately — the operator is not made to wait on the DB.
wruntime.EventsEmit(a.ctx, "qso:logged", id)
@@ -3004,6 +3044,9 @@ func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
if q.MyPOTARef == "" {
q.MyPOTARef = p.MyPOTARef
}
if q.MyIOTA == "" {
q.MyIOTA = p.MyIOTA
}
// MY_NAME = the operator's personal name (profile OpName, e.g. "Greg") — stamped
// like the other My* fields so every QSO carries it, not just those edited by hand.
if q.MyName == "" {
@@ -11769,6 +11812,19 @@ func (a *App) consumeUDPEvents() {
}
switch {
case ev.DecodeCall != "":
// Remember the grid before anything else: a CQ is the one message that
// carries it, and the station may never send another.
if ev.DecodeGrid != "" {
a.decodeGridsMu.Lock()
if a.decodeGrids == nil {
a.decodeGrids = make(map[string]string, 512)
}
if len(a.decodeGrids) > 20000 {
a.decodeGrids = make(map[string]string, 512) // bound a long session
}
a.decodeGrids[strings.ToUpper(ev.DecodeCall)] = ev.DecodeGrid
a.decodeGridsMu.Unlock()
}
// A WSJT-X decode (heard station). Render it on the FlexRadio
// panadapter when the option is on; green + SNR comment, auto-expiring
// after the configured duration. De-duped per call in the Flex backend.
@@ -13422,6 +13478,7 @@ func (a *App) GetStationSettings() (StationSettings, error) {
MyCountry: p.MyCountry,
MySOTARef: p.MySOTARef,
MyPOTARef: p.MyPOTARef,
MyIOTA: p.MyIOTA,
}, nil
}
@@ -13519,6 +13576,10 @@ func (a *App) SaveStationSettings(s StationSettings) error {
p.MyCountry = s.MyCountry
p.MySOTARef = s.MySOTARef
p.MyPOTARef = s.MyPOTARef
// Uppercased on the way in: ADIF spells it EU-005, and an operator typing
// "eu-005" would otherwise put a reference no award matcher recognises on
// every QSO of an activation.
p.MyIOTA = strings.ToUpper(strings.TrimSpace(s.MyIOTA))
return a.profiles.Save(a.ctx, &p)
}
@@ -16368,6 +16429,10 @@ type SpotQuery struct {
Band string `json:"band"`
Mode string `json:"mode"`
POTARef string `json:"pota_ref,omitempty"` // park id if the spot is a POTA activation
// Spotter is the station that sent the spot. Only its continent is wanted, and
// resolving it here rather than in the frontend keeps the one DXCC prefix
// table as the single authority on what continent a callsign is in.
Spotter string `json:"spotter,omitempty"`
}
// SpotStatus is the per-tuple result. Status is one of:
@@ -16400,6 +16465,21 @@ type SpotStatus struct {
County string `json:"county,omitempty"`
State string `json:"state,omitempty"`
NewPOTA bool `json:"new_pota"`
// Grid is the 4-character square this station announced in a CQ on the UDP
// link, and NewGrid says that square has never been worked. Both are empty /
// false for any station this receiver has not decoded — a DX-cluster line
// carries the SPOTTER's grid at best, never the DX's.
Grid string `json:"grid,omitempty"`
NewGrid bool `json:"new_grid"`
// SpotterContinent is the continent of the station that SENT the spot, not of
// the DX. It answers a different question — "is anyone near me hearing this?"
// — which is what makes it worth filtering on: a JA spot on 20 m tells a
// European very little about their own path.
SpotterContinent string `json:"spotter_continent,omitempty"`
// LoTW is true when the DX callsign appears in ARRL's user-activity list, so
// an operator chasing confirmations can skip the stations that will never
// upload. Inert until that list has been downloaded.
LoTW bool `json:"lotw"`
// NewPfx flags a CQ WPX prefix never worked before, and Pfx is that prefix.
// Also orthogonal: a common entity on a worked band can still carry a prefix
// that has never been in the log, which is exactly what a WPX chaser is
@@ -16427,6 +16507,7 @@ type clusterStatusCache struct {
workedCounties map[string]struct{}
workedPOTA map[string]struct{}
workedPfx map[string]struct{}
workedGrids map[string]struct{} // "GRID|MODE", mode normalised like the rest
normMode func(string) string // nil unless digital-mode grouping is on
groupDigital bool // settings the maps were built under —
sameSlot bool // a change rebuilds the snapshot
@@ -16493,6 +16574,10 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// lookup) and worked POTA parks.
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
// per spot — the same shape as the county and POTA sets beside it, which is
// why grids cost nothing under an RBN firehose.
c.workedGrids, _ = a.qso.WorkedGridKeys(a.ctx, c.normMode)
// Worked WPX prefixes, derived from the callsigns we already loaded — no
// extra query. Derived rather than read from the stored PFX column: that
// column is only filled when an import supplied it, and deriving keeps this
@@ -16581,6 +16666,46 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
out[i].NewPOTA = true
}
}
// The spotter's continent, and whether the DX uploads to LoTW. Both are
// in-memory lookups on tables already loaded, so they add nothing per spot.
if a.dxcc != nil && q.Spotter != "" {
// Strip the skimmer suffix first. RBN spotters report as "VU2OY-#" and
// a cluster node as "DL1ABC-2"; the prefix matcher sees an unknown
// callsign and gives up, so EVERY spot came back with no continent and
// the filter silently matched nothing. A real callsign never contains a
// hyphen, so cutting at the first one is safe.
sp := q.Spotter
if i := strings.IndexByte(sp, '-'); i > 0 {
sp = sp[:i]
}
if m, ok := a.dxcc.Lookup(sp); ok {
out[i].SpotterContinent = m.Continent
}
}
if a.lotwUsers != nil {
out[i].LoTW = a.lotwUsers.Lookup(q.Call).IsUser
}
// NEW GRID: the square this station announced in a CQ we decoded. The mode
// is part of the key, so the "group digital modes" option decides whether a
// grid worked on FT8 still counts as new on FT4 — one rule, no branch here.
{
// The length check has to be INSIDE the lock: the decode goroutine
// replaces this map wholesale when it grows too large, so reading len()
// unguarded is a race on the map header, not a cheap fast path.
a.decodeGridsMu.RLock()
g := a.decodeGrids[strings.ToUpper(q.Call)]
a.decodeGridsMu.RUnlock()
if g != "" {
out[i].Grid = g
cm := out[i].Mode
if normMode != nil && cm != "" {
cm = normMode(cm)
}
if _, done := idx.workedGrids[g+"|"+cm]; !done {
out[i].NewGrid = true
}
}
}
// NEW COUNTY: resolve the callsign's home county from the offline ULS
// store (US only; inert until downloaded) and flag if never worked.
if a.uls != nil {
+22
View File
@@ -1,4 +1,26 @@
[
{
"version": "0.24.4",
"date": "",
"en": [
"DX cluster: an L badge next to a callsign marks a station that uploads to LoTW, with a matching LoTW users only filter, and a Spotter continent filter narrows the list by where the spot came FROM — a JA report on 20 m tells a European little about their own path. The filter panel has been tidied along one rule: a switch is a behaviour you turn on or off, chips pick any number from a set. Nothing appears in both shapes any more, the Lock buttons sit in the heading of the section they lock, every section can be cleared the same way, and the whole panel is finally translated.",
"DX cluster: the Spotter continent filter now actually matches. RBN skimmers report as VU2OY-# and cluster nodes as DL1ABC-2, and the suffix was passed straight to the prefix lookup, so every spot came back with no continent and the filter silently selected nothing.",
"A spot now stops being NEW the moment you log it. Logging announced the QSO but never dropped the cluster worked-index snapshot, so the refresh that follows re-read the answer computed BEFORE the contact — a station stayed yellow on the band map and in the cluster with the QSO plainly in the log, until something else happened to rebuild the index.",
"Station information gains an IOTA field (ADIF MY_IOTA, e.g. EU-005), stamped on every QSO like the SOTA and POTA references beside it. The QSO table has carried the field since the first release and both ADIF import and export already handled it — only the station profile could not supply it, so an island activation meant typing the reference on every contact.",
"PowerGenius XL: fixed the amplifier link dropping and reconnecting every few seconds, and the stall while transmitting. The amp pushes status frames on the same socket it answers commands on, and it pushes them constantly once in OPERATE; OpsLog read one line per command and took whatever arrived first as its answer, so the stream slipped permanently one reply behind until a command timed out and the connection was dropped. Replies are now matched to the command that asked for them. Only ever visible over a remote link with the amplifier in OPERATE.",
"No colour on worked stations now does only that: it removes the blue already-worked mark and leaves everything else alone. It used to blank the spot status as well, and the status is what dims a quiet row — so turning the option on made every grey row bright white, which is the opposite of what it is for.",
"Grid squares in the cluster. Every CQ decoded over the WSJT-X UDP link carries the sender grid, and OpsLog now keeps it: a Grid column shows the square and NEW GRID flags one never worked, as a badge, a filled cell and a filter of its own. Whether a grid counts as new follows the group digital modes setting — with it on, a square worked on FT8 is no longer new on FT4; with it off the two are separate. Only stations your own receiver decodes have a grid: a cluster line carries the spotter grid at best, never the DX one. Turn the column on in Columns."
],
"fr": [
"Cluster DX : un badge L à côté de l indicatif signale une station qui utilise LoTW, avec le filtre Utilisateurs LoTW seulement qui va avec, et un filtre Continent du spotter restreint selon l origine du spot — un report JA sur 20 m dit peu de chose à un Européen sur son propre chemin. Le panneau de filtres a été remis d aplomb selon une seule règle : un interrupteur est un comportement qu on active ou non, les pastilles choisissent dans un ensemble. Plus rien n existe sous les deux formes, les boutons de verrouillage sont dans le titre de la section qu ils verrouillent, chaque section s efface de la même façon, et le panneau est enfin traduit.",
"Cluster DX : le filtre Continent du spotter fonctionne enfin. Les skimmers RBN s annoncent en VU2OY-# et les nœuds cluster en DL1ABC-2, et ce suffixe partait tel quel dans la recherche de préfixe — tous les spots revenaient sans continent et le filtre ne sélectionnait rien.",
"Un spot cesse enfin d être NOUVEAU dès que tu l enregistres. La journalisation annonçait le QSO mais ne vidait jamais l instantané de l index des contacts, donc le rafraîchissement qui suit relisait la réponse calculée AVANT le contact — une station restait jaune sur le bandmap et dans le cluster alors que le QSO était bien au log, jusqu à ce qu autre chose reconstruise l index.",
"Les informations station gagnent un champ IOTA (MY_IOTA en ADIF, par exemple EU-005), estampillé sur chaque QSO comme les références SOTA et POTA à côté. La table des QSO portait le champ depuis la première version et l import comme l export ADIF le géraient déjà — seul le profil station ne savait pas le fournir, donc une activation d île obligeait à retaper la référence à chaque contact.",
"PowerGenius XL : corrigé le lien avec l ampli qui tombait et se reconnectait toutes les quelques secondes, et le blocage en émission. L ampli pousse des trames d état sur la socket même où il répond aux commandes, et il en pousse en permanence dès qu il est en OPERATE ; OpsLog lisait une ligne par commande et prenait la première arrivée pour sa réponse, donc le flux glissait définitivement d une réponse de retard jusqu à expiration et fermeture du lien. Les réponses sont désormais appariées à la commande qui les a demandées. Visible uniquement en liaison distante avec l ampli en OPERATE.",
"Aucune couleur sur les stations déjà contactées ne fait plus que ça : la marque bleue disparaît, le reste ne bouge pas. L option vidait aussi le statut du spot, or c est le statut qui estompe une ligne sans intérêt — l activer rendait donc toutes les lignes grises blanches et éclatantes, soit l inverse du but recherché.",
"Les carrés locator dans le cluster. Chaque CQ décodé sur le lien UDP WSJT-X porte le grid de l émetteur, et OpsLog le conserve désormais : une colonne Grid affiche le carré et NOUV GRID signale celui jamais contacté, en badge, en cellule remplie et avec son propre filtre. Ce qui compte comme nouveau suit le réglage de regroupement des modes digitaux — activé, un carré fait en FT8 n est plus neuf en FT4 ; désactivé, les deux sont distincts. Seules les stations décodées par ton propre récepteur ont un grid : une ligne de cluster porte le grid du spotter au mieux, jamais celui du DX. Colonne à activer dans Colonnes."
]
},
{
"version": "0.24.3",
"date": "",
+146 -101
View File
@@ -1487,10 +1487,15 @@ export default function App() {
// is an OR across all of them, as it already was for a worked callsign.
// 'new-call' is a DISPLAY status, produced by the slot-highlight option rather
// than by the backend, so it is named here and not in SpotStatusKey.
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx' | 'new-call';
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx' | 'new-call' | 'new-grid';
// Display options, shared with the band map through lib/spotDisplay. Kept in
// localStorage (mirrored to settings by writeUiPref) so both panels and the
// filter predicate read one source without prop-drilling through three levels.
// LoTW-only, and the spotter's continent. Both narrow the list by a property
// of the station rather than by what the spot is worth, which is why they sit
// beside Hide worked and not among the status chips.
const [clusterLotwOnly, setClusterLotwOnly] = useState(() => localStorage.getItem('opslog.clusterLotwOnly') === '1');
const [clusterSpotterConts, setClusterSpotterConts] = useState<Set<string>>(() => lsSet<string>('opslog.clusterSpotterCont'));
const [clusterMuteWorked, setClusterMuteWorked] = useState(() => localStorage.getItem('opslog.clusterMuteWorked') === '1');
const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => localStorage.getItem('opslog.clusterSlotHighlight') === '1');
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
@@ -1511,11 +1516,13 @@ export default function App() {
writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0');
writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0');
writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter]));
writeUiPref('opslog.clusterSpotterCont', JSON.stringify([...clusterSpotterConts]));
writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter]));
writeUiPref('opslog.clusterSearch', clusterSearch);
writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0');
}, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode,
clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked]);
clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked,
clusterLotwOnly, clusterSpotterConts]);
// Bands shown side-by-side in the Band Map tab (portable).
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
@@ -1591,7 +1598,7 @@ export default function App() {
// worked_slot must be carried explicitly like every other field: this map is
// assembled field by field, so a backend flag that nobody copies here simply
// never reaches the panels — silently, since the extra key is just dropped.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus);
@@ -1624,13 +1631,13 @@ export default function App() {
const refreshSpotStatuses = useCallback(async () => {
const cur = spotsRef.current;
if (!cur.length) return;
const queries: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const queries: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
const seen = new Set<string>();
for (const s of cur) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k)) continue;
seen.add(k);
queries.push({ call: s.dx_call, band: s.band ?? '', mode: inferSpotMode(s.comment ?? '', s.freq_hz), pota_ref: (s as any).pota_ref ?? '' });
queries.push({ call: s.dx_call, band: s.band ?? '', mode: inferSpotMode(s.comment ?? '', s.freq_hz), pota_ref: (s as any).pota_ref ?? '', spotter: s.spotter ?? '' });
}
if (!queries.length) return;
try {
@@ -1641,7 +1648,7 @@ export default function App() {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = {
status: r.status ?? '', country: r.country, continent: (r as any).continent,
worked_call: !!(r as any).worked_call, worked_slot: !!(r as any).worked_slot, new_county: !!(r as any).new_county, county: (r as any).county, state: (r as any).state,
worked_call: !!(r as any).worked_call, worked_slot: !!(r as any).worked_slot, new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
}
@@ -1926,7 +1933,7 @@ export default function App() {
// === Station ===
const [station, setStation] = useState<StationSettings>({
callsign: '', operator: '',
my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '',
my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '', my_iota: '',
});
const [showFirstRun, setShowFirstRun] = useState(false);
myCallRef.current = (station.callsign || '').toUpperCase();
@@ -2717,7 +2724,7 @@ export default function App() {
// Resolve unknown statuses before the rows go in.
try {
const known = spotStatusRef.current;
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const unknown: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
const seen = new Set<string>();
for (const s of batch) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
@@ -2727,6 +2734,7 @@ export default function App() {
call: s.dx_call, band: s.band ?? '',
mode: inferSpotMode(s.comment ?? '', s.freq_hz),
pota_ref: (s as any).pota_ref ?? '',
spotter: s.spotter ?? '',
});
}
if (unknown.length > 0) {
@@ -2741,7 +2749,7 @@ export default function App() {
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, county: (r as any).county, state: (r as any).state,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
@@ -3187,14 +3195,14 @@ export default function App() {
// "new-slot" because the lookup key carried mode="".
useEffect(() => {
const t = window.setTimeout(async () => {
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const unknown: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
const seen = new Set<string>();
for (const s of spots) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k) || spotStatus[k]) continue;
seen.add(k);
unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '' });
unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '', spotter: s.spotter ?? '' });
}
if (unknown.length === 0) return;
try {
@@ -3209,7 +3217,7 @@ export default function App() {
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, county: (r as any).county, state: (r as any).state,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
@@ -4705,9 +4713,23 @@ export default function App() {
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
|| (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'));
|| (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'))
|| (!!e?.new_grid && clusterStatusFilter.has('new-grid'));
if (!matches) return false;
}
// LoTW only, and the spotter's continent. Both are properties of the
// station rather than judgements about the spot, so they AND with the
// status chips instead of joining that OR: "a new band, and from Europe".
if (clusterLotwOnly || clusterSpotterConts.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
// An unresolved spot is not filtered out. The status arrives a moment
// after the row does, and dropping it meanwhile made the list flicker.
if (e) {
if (clusterLotwOnly && !e.lotw) return false;
if (clusterSpotterConts.size > 0 && e.spotter_continent && !clusterSpotterConts.has(e.spotter_continent)) return false;
}
}
if (clusterHideWorked) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
@@ -4734,11 +4756,46 @@ export default function App() {
// The Log4OM-style cluster filter sidebar (callsign search, hide-worked,
// group, band/mode/status/source). Rendered both in the Cluster tab and the
// Main-view cluster pane; toggled by clusterShowFilters.
// One rule for the whole panel, because two shapes for the same kind of choice
// is what made it unreadable:
//
// SWITCH — a behaviour that is on or off (hide worked, group duplicates).
// CHIPS — pick any number from a set; none picked means all (status, mode,
// continent). Selected is solid, unselected is the same chip faded,
// so the palette teaches the colour code even while switched off.
//
// Nothing appears in both shapes. A control that exists twice is not more
// discoverable, it is one control the operator has to recognise twice.
const fSection = (label: string, children: any, right?: any) => (
<div>
<div className="flex items-center justify-between mb-1 min-h-[16px]">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
{right}
</div>
{children}
</div>
);
const fSwitch = (label: string, on: boolean, set: (v: boolean) => void) => (
<label className="flex items-center gap-2 cursor-pointer rounded px-1 py-1 -mx-1 hover:bg-accent/40">
<Checkbox checked={on} onCheckedChange={(c) => set(!!c)} />
<span className="leading-none">{label}</span>
</label>
);
const F_CHIP = 'px-1.5 py-[3px] rounded-md border text-[10px] font-bold tracking-wider transition-opacity';
const fChip = (key: string, label: string, cls: string, on: boolean, toggle: () => void) => (
<button key={key} type="button" onClick={toggle}
className={cn(F_CHIP, on ? cls : `${cls} opacity-40 hover:opacity-80`)}>
{label}
</button>
);
const renderClusterFilters = () => (
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
<div className="px-2.5 py-2 border-b border-border/60 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</span>
<button type="button" onClick={toggleClusterFilters} title="Hide filters"
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('clu.filters')}</span>
<button type="button" onClick={toggleClusterFilters} title={t('clu.hideFilters')}
className="text-muted-foreground hover:text-foreground">
<X className="size-3.5" />
</button>
@@ -4747,56 +4804,48 @@ export default function App() {
{/* Callsign search */}
<Input
className="h-7 text-xs font-mono uppercase"
placeholder="Search call…"
placeholder={t('clu.searchCall')}
value={clusterSearch}
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
/>
{/* Toggles */}
<div className="space-y-1.5">
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
Hide worked
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
Group duplicates
</label>
{/* Two ways to cut through a busy cluster, and they compose: mute what
is done, light up what is not. They sit HERE, with the other things
an operator changes mid-run, and not in the preferences dialog.
Both drive the band map as well, through lib/spotDisplay. */}
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterMuteWorked}
onCheckedChange={(c) => { const v = !!c; setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); }} />
{t('clu.muteWorkedShort')}
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterSlotHighlight}
onCheckedChange={(c) => { const v = !!c; setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); }} />
{t('clu.slotHighlightShort')}
</label>
{/* Behaviours: each is on or off, and none of them narrows by a property
of the station. The last two also drive the band map, via
lib/spotDisplay they live here rather than in Preferences because
they are changed while working a run. */}
<div className="space-y-0.5">
{fSwitch(t('clu.hideWorked'), clusterHideWorked, setClusterHideWorked)}
{fSwitch(t('clu.groupDup'), clusterGroup, setClusterGroup)}
{fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })}
{fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })}
{fSwitch(t('clu.lotwOnly'), clusterLotwOnly, (v) => { setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); })}
</div>
{/* Band filter — multi-select listbox */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setClusterLockBand((v) => !v)}
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
clusterLockBand ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
title="Lock to the entry strip's current band"
>
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
</button>
{clusterBands.size > 0 && (
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
{/* The SPOTTER's continent, not the DX's: this asks whether anyone near
you is hearing the band at all. Chips rather than a dropdown seven
two-letter codes fit on two lines, they read as a set the way Status
and Mode do, and several can be picked at once, which "EU or NA" needs
and a dropdown cannot express. */}
{fSection(t('clu.spotterCont'),
<div className="flex flex-wrap gap-1">
{['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'].map((c) => fChip(
c, c, 'bg-muted text-foreground border-border',
clusterSpotterConts.has(c),
() => setClusterSpotterConts((cur) => {
const n = new Set(cur);
if (n.has(c)) n.delete(c); else n.add(c);
return n;
}),
))}
</div>,
clusterSpotterConts.size > 0 ? (
<button type="button" onClick={() => setClusterSpotterConts(new Set())}
className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.clear')}</button>
) : undefined,
)}
</div>
</div>
{/* Band filter — multi-select listbox */}
{fSection(t('clu.bands'),
<div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
{bands.map((b) => {
const on = clusterBands.has(b);
@@ -4812,23 +4861,25 @@ export default function App() {
</button>
);
})}
</div>
</div>
{/* Mode lock */}
</div>,
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setClusterLockMode((v) => !v)}
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
clusterLockMode ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
title="Only show spots whose mode matches the entry strip"
onClick={() => setClusterLockBand((v) => !v)}
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
clusterLockBand ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
title={t('clu.lockBandTitle')}
>
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
</button>
{clusterBands.size > 0 && (
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.clear')}</button>
)}
</div>,
)}
{/* Status filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
{fSection(t('clu.status'),
<div className="flex flex-wrap gap-1">
{([
{ k: 'new' as SpotFilterKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
@@ -4843,55 +4894,49 @@ export default function App() {
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
// Only ever set for a station this receiver decoded over the UDP link.
{ k: 'new-grid' as SpotFilterKey, label: 'NEW GRID', cls: 'bg-muted text-foreground border-border' },
// Blue, like the already-worked call in the grid. Selecting it keeps
// worked spots; the separate "Hide worked" checkbox drops them — they
// are opposite controls, so don't use both at once.
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</div>
]).map((s) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })))}
</div>,
clusterStatusFilter.size > 0 ? (
<button type="button" onClick={() => setClusterStatusFilter(new Set())}
className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.clear')}</button>
) : undefined,
)}
{/* Mode filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
{fSection(t('clu.mode'),
<div className="flex flex-wrap gap-1">
{([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
]).map((s) => {
const on = clusterModeFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</div>
]).map((s) => fChip(s.k, s.label, s.cls, clusterModeFilter.has(s.k),
() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })))}
</div>,
<button type="button" onClick={() => setClusterLockMode((v) => !v)}
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
clusterLockMode ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
title={t('clu.lockModeTitle')}>
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {mode}
</button>,
)}
{/* Source */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
{fSection(t('clu.source'),
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder={t('clu.allSources')} /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All sources</SelectItem>
<SelectItem value="_">{t('clu.allSources')}</SelectItem>
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</Select>,
)}
</div>
</div>
);
+13 -23
View File
@@ -38,9 +38,6 @@ type SpotStatusEntry = {
new_county?: boolean;
new_pota?: boolean;
new_pfx?: boolean;
// muted: the status was emptied on purpose by the "no colour on worked
// stations" option, so an empty status here is a choice, not an unknown.
muted?: boolean;
};
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
@@ -51,12 +48,13 @@ type SpotStatusEntry = {
//
// Their colours come from lib/spotMarkers, shared with the cluster list: the
// same fact must not be violet in one panel and green in the next.
// The map shows three of the four. A new PREFIX is left to the cluster list: the
// The map shows three of the five. A new PREFIX and a new GRID are left to the
// cluster list, which has the width to name them: the
// pill is 22 px tall, and a fourth segment turns the strip into a colour code
// nobody can read at a glance. Add it here the day the strip earns more room.
const BMP_MARKERS = SPOT_MARKERS.filter((m) => m.key !== 'new_pfx');
const BMP_MARKERS = SPOT_MARKERS.filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');
const markersFor = (e: SpotStatusEntry | undefined) =>
activeMarkers(e).filter((m) => m.key !== 'new_pfx');
activeMarkers(e).filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');
// The legend spells the markers out; the cluster list's badges are abbreviated
// ("NEW CTY") because they sit in a narrow cell, and there is room here.
@@ -152,7 +150,7 @@ function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; labe
// Human-readable label for a spot status — used in the pill hover tooltip
// so the operator can see WHY a spot is coloured the way it is.
function statusLabel(s: string, t: (k: string) => string, muted = false): string {
function statusLabel(s: string, t: (k: string) => string): string {
switch (s) {
case 'new': return t('bmp.statusNew');
case 'new-band': return t('bmp.statusNewBand');
@@ -160,11 +158,10 @@ function statusLabel(s: string, t: (k: string) => string, muted = false): string
case 'new-slot': return t('bmp.statusNewSlot');
case 'new-call': return t('bmp.statusNewCall');
case 'worked': return t('bmp.statusWorked');
// An empty status means the entity could not be resolved — EXCEPT when the
// "no colour on worked stations" option emptied it on purpose. Saying
// "entity not resolved" there was a flat contradiction of the country
// printed two words earlier in the same tooltip.
default: return muted ? t('bmp.statusMuted') : t('bmp.statusUnresolved');
// An empty status means the entity could not be resolved. Nothing else
// empties it: the mute option leaves the status alone and takes only the
// already-worked-callsign mark.
default: return t('bmp.statusUnresolved');
}
}
@@ -177,14 +174,7 @@ const QUIET_STYLE = {
dot: 'fill-border',
};
function statusStyle(s: string, muted = false): { pill: string; bar: string; line: string; dot: string } {
// A muted spot must land on QUIET, never on the default branch below. That
// default paints bg-primary/60 — the theme's burnt orange at 60 % over a dark
// card, i.e. brown — because it was written for the rare unresolved entity and
// orange is this app's "look here" colour. With the mute option on, most of
// the map is muted, so the whole band map turned brown and the quietest spots
// shouted the loudest.
if (muted) return QUIET_STYLE;
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
// pill = full pill background+text+border
// bar = thick left accent inside the pill
// line = SVG leader stroke (visible on hover)
@@ -581,7 +571,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
{placed.map((p, i) => {
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
const st = spotStatus[k]?.status ?? '';
const style = statusStyle(st, spotStatus[k]?.muted);
const style = statusStyle(st);
const labelMidY = p.labelY + PILL_H / 2;
const bumped = Math.abs(p.freqY - labelMidY) > 0.5;
return (
@@ -629,7 +619,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
const entry = spotStatus[k];
const st = entry?.status ?? '';
const style = statusStyle(st, entry?.muted);
const style = statusStyle(st);
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
return (
<button
@@ -642,7 +632,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
'hover:translate-x-0.5 hover:shadow',
style.pill,
)}
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t, entry?.muted)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
>
{/* Left accent strip. With no extra marker it repeats the status
colour, exactly as before; otherwise it splits into one
+35 -2
View File
@@ -64,6 +64,11 @@ export type SpotStatusEntry = {
new_pota?: boolean;
new_pfx?: boolean;
pfx?: string;
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
lotw?: boolean;
spotter_continent?: string;
grid?: string;
new_grid?: boolean;
};
type Props = {
@@ -188,7 +193,7 @@ function statusColor(s: SpotStatusEntry | undefined): string | null {
function isDull(s: SpotStatusEntry | undefined): boolean {
if (!s || !s.status) return false;
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx);
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx || s.new_grid);
}
const makeColCatalog = (t: TFn): ColEntry[] => [
@@ -220,7 +225,21 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
cellRenderer: (p: any) => {
const s = statusFor(p);
const color = s?.status === 'new' ? null : s?.worked_call ? WKD : null;
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
return (
<span style={{ color: color ?? undefined, fontWeight: 700 }}>
{p.value ?? ''}
{/* A single letter rather than a word: the column is 120 px and holds a
callsign. It stays the muted-blue of a confirmation, never a status
colour — whether a station uploads to LoTW says nothing about
whether the spot is worth chasing. */}
{s?.lotw ? (
<span title={t('clu.lotwBadge')} style={{
marginLeft: 4, padding: '0 3px', borderRadius: 3, fontSize: 9, verticalAlign: 'middle',
background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)',
}}>L</span>
) : null}
</span>
);
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
@@ -247,6 +266,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
if (s?.new_grid) parts.push(t('clg2.newGrid'));
return parts.join(' ');
},
cellRenderer: (p: any) => {
@@ -267,6 +287,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
if (s?.new_grid) parts.push({ text: t('clg2.newGrid'), color: markerColour('new_grid') });
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return (
<span style={{ whiteSpace: 'nowrap' }}>
@@ -343,6 +364,18 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: { color: 'var(--muted-foreground)' }) as any,
tooltipValueGetter: (p: any) => (statusFor(p)?.new_pfx ? t('clg2.tipNewPfx') : undefined),
},
{
group: 'Geo', label: t('clg2.c.grid'), colId: 'grid',
headerName: t('clg2.c.grid'), width: 70, cellClass: 'font-mono',
// Not on the spot: the square a station announced in a CQ that THIS receiver
// decoded over the WSJT-X link, so it is filled for the digital watering
// hole being monitored and empty everywhere else. A cluster line carries the
// spotter's grid at best, never the DX's.
valueGetter: (p: any) => statusFor(p)?.grid ?? '',
cellStyle: (p: any) => (statusFor(p)?.new_grid ? fillStyle(markerColour('new_grid')) : null) as any,
cellRenderer: (p: any) => cellText(p.value, null),
tooltipValueGetter: (p: any) => (statusFor(p)?.new_grid ? t('clg2.tipNewGrid') : undefined),
},
{
group: 'Geo', label: t('clg2.c.county'), colId: 'county',
headerName: t('clg2.c.county'), width: 130, cellClass: 'font-mono',
+6 -1
View File
@@ -144,7 +144,7 @@ const emptyProfile = (): Profile => ({
my_grid: '', my_country: '',
my_state: '', my_cnty: '',
my_street: '', my_city: '', my_postal_code: '',
my_sota_ref: '', my_pota_ref: '',
my_sota_ref: '', my_pota_ref: '', my_iota: '',
my_rig: '', my_antenna: '',
tx_pwr: undefined,
is_active: false,
@@ -1854,6 +1854,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
my_grid: (activeProfile.my_grid ?? '').trim().toUpperCase(),
my_sota_ref: (activeProfile.my_sota_ref ?? '').trim().toUpperCase(),
my_pota_ref: (activeProfile.my_pota_ref ?? '').trim().toUpperCase(),
my_iota: (activeProfile.my_iota ?? '').trim().toUpperCase(),
} as any);
}
await SaveLookupSettings(lookup as any);
@@ -2013,6 +2014,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label>{t('station.pota')}</Label>
<Input className="font-mono uppercase" value={p.my_pota_ref ?? ''} onChange={(e) => updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
</div>
<div className="space-y-1">
<Label>{t('station.iota')}</Label>
<Input className="font-mono uppercase" value={p.my_iota ?? ''} onChange={(e) => updateActive({ my_iota: e.target.value })} placeholder="EU-005" />
</div>
</div>
</>
);
File diff suppressed because one or more lines are too long
+16 -35
View File
@@ -31,27 +31,9 @@ type Entry = {
new_county?: boolean;
new_pota?: boolean;
new_pfx?: boolean;
muted?: boolean;
new_grid?: boolean;
} | undefined;
// bringsNothingNew: the ENTITY is resolved and worked, and no other dimension
// (county, park, prefix) is new. Same test the cluster list already used to dim
// a row — muting reuses it rather than inventing a second notion of "done".
//
// Entity-level, NOT callsign-level: an operator can be on their 421st Bulgarian
// and still have never worked that particular station. That spot is muted here
// by design — it brings nothing to an award — and turning on the slot option is
// what brings it back, which is why the promotion above runs first.
//
// Note what is NOT muted: a status of new-band / new-slot survives, because
// having worked that callsign once on another band says nothing about the band
// in front of you.
function bringsNothingNew(s: Entry): boolean {
if (!s || !s.status) return false; // unresolved — never hide, it would flicker
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
return !(s.new_pota || s.new_county || s.new_pfx);
}
// applySpotDisplay rewrites a status entry per the options, so every consumer —
// colour, badge, status text — follows from one decision instead of each panel
// re-deriving it.
@@ -59,27 +41,26 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
if (!s) return s;
let e = s;
// ORDER MATTERS, and it is the whole difference between the two options
// composing and one cancelling the other.
//
// Slot promotion runs FIRST. A callsign not yet worked on this band and mode
// is not "done", so it must never be swallowed by the mute below — yet the
// mute test only looks at the entity, and an unworked callsign inside a worked
// entity is precisely the spot the second option exists to surface. Promoting
// first protects it for free: bringsNothingNew() returns false on new-slot.
// Slot promotion runs first: a callsign not yet worked on this band and mode
// is not done, whatever the entity says, so it earns a status before the mute
// below can take its colour away.
if (o.slotHighlight && e.worked_slot === false && (!e.status || e.status === 'worked')) {
e = { ...e, status: 'new-call' } as NonNullable<T>;
}
if (o.muteWorked && bringsNothingNew(e)) {
// Strip everything that paints: the row keeps its data, loses its emphasis.
// Mute drops the blue already-worked-callsign mark, and NOTHING else.
//
// muted says WHY the status went empty. An empty status already meant
// "entity not resolved" in the band map, so without this flag every muted
// spot claimed its entity was unknown — with the country printed right next
// to it. Blanking is still what drives the colour, the badges and the
// ranking; muted only lets the tooltip stay honest.
return { ...e, status: '', worked_call: false, muted: true } as T;
// It used to blank the status as well, on the theory that a spot bringing no
// novelty should stop painting entirely. That was wrong twice over. The status
// is what the cluster list reads to DIM a row, so blanking it turned every
// quiet grey row bright white — the option made the list louder, not quieter.
// And an empty status means "entity not resolved" everywhere else, so muted
// spots had to carry a flag saying they did not really mean that.
//
// Leaving the status alone costs nothing: a worked entity already renders with
// no colour and gets dimmed, so removing the blue is the entire job.
if (o.muteWorked) {
e = { ...e, worked_call: false } as NonNullable<T>;
}
return e;
}
+5 -1
View File
@@ -15,7 +15,10 @@
// statuses, blue is "callsign worked", green is POTA
// worked blue — matches the WKD-CALL badge the cluster list has always used
// prefix yellow — unchanged
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call';
// grid magenta — the last hue in the categorical set that is not already
// spoken for here and does not read as a status; a grid is
// never urgent the way a new entity is
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid';
export type SpotMarker = {
key: SpotMarkerKey;
@@ -28,6 +31,7 @@ export const SPOT_MARKERS: SpotMarker[] = [
{ key: 'new_pota', colour: 'var(--success)', labelKey: 'clg2.newPota' },
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
];
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.24.3';
export const APP_VERSION = '0.24.4';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+14
View File
@@ -2987,6 +2987,7 @@ export namespace main {
band: string;
mode: string;
pota_ref?: string;
spotter?: string;
static createFrom(source: any = {}) {
return new SpotQuery(source);
@@ -2998,6 +2999,7 @@ export namespace main {
this.band = source["band"];
this.mode = source["mode"];
this.pota_ref = source["pota_ref"];
this.spotter = source["spotter"];
}
}
export class SpotStatus {
@@ -3012,6 +3014,10 @@ export namespace main {
county?: string;
state?: string;
new_pota: boolean;
grid?: string;
new_grid: boolean;
spotter_continent?: string;
lotw: boolean;
new_pfx: boolean;
pfx?: string;
worked_slot: boolean;
@@ -3033,6 +3039,10 @@ export namespace main {
this.county = source["county"];
this.state = source["state"];
this.new_pota = source["new_pota"];
this.grid = source["grid"];
this.new_grid = source["new_grid"];
this.spotter_continent = source["spotter_continent"];
this.lotw = source["lotw"];
this.new_pfx = source["new_pfx"];
this.pfx = source["pfx"];
this.worked_slot = source["worked_slot"];
@@ -3166,6 +3176,7 @@ export namespace main {
my_country: string;
my_sota_ref: string;
my_pota_ref: string;
my_iota: string;
static createFrom(source: any = {}) {
return new StationSettings(source);
@@ -3179,6 +3190,7 @@ export namespace main {
this.my_country = source["my_country"];
this.my_sota_ref = source["my_sota_ref"];
this.my_pota_ref = source["my_pota_ref"];
this.my_iota = source["my_iota"];
}
}
export class StationTestResult {
@@ -3703,6 +3715,7 @@ export namespace profile {
my_city: string;
my_postal_code: string;
my_sota_ref: string;
my_iota: string;
my_pota_ref: string;
my_rig: string;
my_antenna: string;
@@ -3740,6 +3753,7 @@ export namespace profile {
this.my_city = source["my_city"];
this.my_postal_code = source["my_postal_code"];
this.my_sota_ref = source["my_sota_ref"];
this.my_iota = source["my_iota"];
this.my_pota_ref = source["my_pota_ref"];
this.my_rig = source["my_rig"];
this.my_antenna = source["my_antenna"];
@@ -0,0 +1,7 @@
-- MY_IOTA: the island reference the station operates FROM, e.g. "EU-005".
--
-- An official ADIF field, and the QSO table has carried my_iota since 0001 —
-- only the station profile could not supply it, so an island activation had to
-- have the reference typed on every contact or added afterwards. Blank by
-- default: the overwhelming majority of stations are not on an island.
ALTER TABLE station_profiles ADD COLUMN my_iota TEXT NOT NULL DEFAULT '';
+2
View File
@@ -59,6 +59,7 @@ type Event struct {
// A WSJT-X Decode (heard station) to render on the panadapter.
DecodeCall string // transmitting (DE) callsign
DecodeGrid string // 4-char grid, CQ decodes only
DecodeFreqHz int64 // RF frequency (dial + audio offset)
DecodeSNR int // reported SNR (dB)
DecodeCQ bool // the decode was a CQ
@@ -300,6 +301,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
return
}
ev.DecodeCall = w.DecodeCall
ev.DecodeGrid = w.DecodeGrid
ev.DecodeFreqHz = dial + w.DeltaFreqHz
ev.DecodeSNR = w.SNR
ev.DecodeCQ = w.IsCQ
+33 -10
View File
@@ -51,6 +51,7 @@ type WSJTEvent struct {
// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
IsDecode bool
DecodeCall string // the sender (DE) callsign extracted from the message text
DecodeGrid string // 4-char grid, CQ decodes only — the exchange carries none
DeltaFreqHz int64 // audio offset within the passband (Hz)
SNR int // reported signal-to-noise (dB)
IsCQ bool // the decode was a CQ call
@@ -239,13 +240,14 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
if err != nil {
return WSJTEvent{}, false, err
}
call, isCQ := wsjtSender(msg)
call, isCQ, grid := wsjtSender(msg)
if call == "" {
return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
}
ev.IsDecode = true
ev.DecodeCall = call
ev.IsCQ = isCQ
ev.DecodeGrid = grid
ev.DeltaFreqHz = int64(df)
ev.SNR = int(snr)
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
@@ -263,17 +265,20 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
return WSJTEvent{}, false, nil
}
// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and
// whether it was a CQ. Grammar:
// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message,
// whether it was a CQ, and the grid when the message carries one. Grammar:
//
// CQ [modifier] <de_call> [grid] → de_call, isCQ=true
// CQ [modifier] <de_call> [grid] → de_call, isCQ=true, grid
// <to_call> <de_call> [report|…] → de_call, isCQ=false
//
// Only a CQ carries a grid: the standard exchange puts a signal report in that
// third slot, never a locator.
//
// Returns "" for free-text / telemetry / hashed-call messages we can't resolve.
func wsjtSender(message string) (call string, isCQ bool) {
func wsjtSender(message string) (call string, isCQ bool, grid string) {
f := strings.Fields(strings.ToUpper(strings.TrimSpace(message)))
if len(f) == 0 {
return "", false
return "", false, ""
}
if f[0] == "CQ" {
// Skip an optional modifier after CQ (DX / a region like NA / a zone like
@@ -283,15 +288,33 @@ func wsjtSender(message string) (call string, isCQ bool) {
idx = 2
}
if idx < len(f) && looksLikeCall(f[idx]) {
return f[idx], true
if idx+1 < len(f) && isGridField(f[idx+1]) {
grid = f[idx+1]
}
return "", true
return f[idx], true, grid
}
return "", true, ""
}
// Standard exchange: the DE (sender) call is the second token.
if len(f) >= 2 && looksLikeCall(f[1]) {
return f[1], false
return f[1], false, ""
}
return "", false
return "", false, ""
}
// isGridField reports a 4-character Maidenhead field+square (JN36).
//
// RR73 is the reason this is not a bare pattern match: it is a sign-off, not a
// locator, yet R falls inside AR and 73 inside 0099, so it satisfies the
// Maidenhead shape exactly. WSJT-X never puts it in the slot after a CQ call,
// but a station sending "CQ RR73" style free text would silently plant a
// nonexistent grid in the log's grid index, and nothing downstream could tell.
func isGridField(s string) bool {
if len(s) != 4 || s == "RR73" {
return false
}
return s[0] >= 'A' && s[0] <= 'R' && s[1] >= 'A' && s[1] <= 'R' &&
s[2] >= '0' && s[2] <= '9' && s[3] >= '0' && s[3] <= '9'
}
// looksLikeCall is a loose callsign test: 312 chars of AZ/09//, with at least
+24 -16
View File
@@ -7,26 +7,34 @@ func TestWSJTSender(t *testing.T) {
msg string
wantCall string
wantCQ bool
wantGrid string
}{
{"CQ K1ABC FN42", "K1ABC", true},
{"CQ DX W2XYZ EM12", "W2XYZ", true}, // modifier "DX" skipped
{"CQ NA VE3ABC FN03", "VE3ABC", true}, // region modifier skipped
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true},// zone modifier skipped
{"W2XYZ K1ABC -10", "K1ABC", false}, // exchange → sender is 2nd call
{"W2XYZ K1ABC R-10", "K1ABC", false},
{"W2XYZ K1ABC RR73", "K1ABC", false},
{"F4BPO K1ABC/P 73", "K1ABC/P", false},// portable call kept
{"CQ F/DL1ABC JO31", "F/DL1ABC", true},// compound prefix
{"CQ K1ABC FN42", "K1ABC", true, "FN42"},
{"CQ DX W2XYZ EM12", "W2XYZ", true, "EM12"}, // modifier "DX" skipped
{"CQ NA VE3ABC FN03", "VE3ABC", true, "FN03"}, // region modifier skipped
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true, "PM95"}, // zone modifier skipped
{"W2XYZ K1ABC -10", "K1ABC", false, ""}, // exchange → sender is 2nd call
{"W2XYZ K1ABC R-10", "K1ABC", false, ""},
{"W2XYZ K1ABC RR73", "K1ABC", false, ""},
{"F4BPO K1ABC/P 73", "K1ABC/P", false, ""}, // portable call kept
{"CQ F/DL1ABC JO31", "F/DL1ABC", true, "JO31"}, // compound prefix, grid still valid
{"CQ K1ABC", "K1ABC", true, ""}, // CQ without a grid
{"CQ K1ABC RR73", "K1ABC", true, ""}, // RR73 is a sign-off, NOT grid RR73
{"CQ K1ABC FN4", "K1ABC", true, ""}, // 3 chars is not a field+square
{"CQ K1ABC FN42AB", "K1ABC", true, ""}, // 6-char: WSJT-X never sends it here
{"CQ K1ABC 73", "K1ABC", true, ""}, // bare sign-off
{"CQ K1ABC SS42", "K1ABC", true, ""}, // S is past R — no such field
// Non-callsign / free text → no sender.
{"TNX 73 GL", "", false},
{"K1ABC RR73", "", false}, // only one call + a token → 2nd token not a call
{"", "", false},
{"CQ CQ CQ", "", true}, // CQ but no resolvable call
{"TNX 73 GL", "", false, ""},
{"K1ABC RR73", "", false, ""}, // only one call + a token → 2nd token not a call
{"", "", false, ""},
{"CQ CQ CQ", "", true, ""}, // CQ but no resolvable call
}
for _, c := range cases {
call, cq := wsjtSender(c.msg)
if call != c.wantCall || cq != c.wantCQ {
t.Errorf("wsjtSender(%q) = (%q,%v), want (%q,%v)", c.msg, call, cq, c.wantCall, c.wantCQ)
call, cq, grid := wsjtSender(c.msg)
if call != c.wantCall || cq != c.wantCQ || grid != c.wantGrid {
t.Errorf("wsjtSender(%q) = (%q,%v,%q), want (%q,%v,%q)",
c.msg, call, cq, grid, c.wantCall, c.wantCQ, c.wantGrid)
}
}
}
+35 -11
View File
@@ -240,12 +240,10 @@ func (c *Client) authLocked() error {
if _, err := fmt.Fprintf(c.conn, "C%d|auth code=%s\n", id, c.password); err != nil {
return err
}
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
line, err := c.reader.ReadString('\n')
line, err := c.readReplyLocked(fmt.Sprintf("R%d|", id))
if err != nil {
return err
}
line = strings.TrimSpace(line)
applog.Printf("pgxl: auth reply=%q (try %d)", line, try)
hex, msg := "", ""
if p := strings.SplitN(line, "|", 3); len(p) >= 2 {
@@ -264,6 +262,39 @@ func (c *Client) authLocked() error {
return fmt.Errorf("powergenius: authentication failed after 4 tries (R|%s|) — check the remote code", lastHex)
}
// readReplyLocked reads until the reply carrying `want` as its prefix arrives,
// feeding every unsolicited frame it passes to parse() on the way.
//
// The amplifier PUSHES status frames ("S0|state=…") on the same socket, and it
// pushes them constantly once it is in OPERATE — power, SWR and temperature all
// move while transmitting. The old code read exactly one line per command and
// took whatever came first as its answer, so a single pushed frame put the
// stream permanently one reply behind: every later command read the PREVIOUS
// command's answer, and the last one waited out the 3 s deadline, failed, and
// dropped the connection. That is the reconnect-every-few-seconds seen in the
// field, and the stall in transmit — command() holds the mutex across that whole
// dead wait, so anything else touching the amplifier queued behind it.
//
// It only showed up remotely and in OPERATE: on a LAN with an idle amplifier
// there is almost nothing to push and the race hardly ever opens.
func (c *Client) readReplyLocked(want string) (string, error) {
deadline := time.Now().Add(ioTimeout)
for {
_ = c.conn.SetReadDeadline(deadline)
line, err := c.reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
// Every frame is worth having, ours or not — a pushed status is fresher
// than the one we were about to ask for.
c.parse(line)
if strings.HasPrefix(line, want) {
return line, nil
}
}
}
func (c *Client) dropConn() {
c.mu.Lock()
if c.conn != nil {
@@ -286,14 +317,7 @@ func (c *Client) command(cmd string) (string, error) {
if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil {
return "", err
}
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
line, err := c.reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
c.parse(line)
return line, nil
return c.readReplyLocked(fmt.Sprintf("R%d|", id))
}
// parse handles "R<id>|0|<k=v …>" and "S0|<k=v …>" status lines.
+10 -8
View File
@@ -52,6 +52,7 @@ type Profile struct {
MyCity string `json:"my_city"`
MyPostalCode string `json:"my_postal_code"`
MySOTARef string `json:"my_sota_ref"`
MyIOTA string `json:"my_iota"`
MyPOTARef string `json:"my_pota_ref"`
MyRig string `json:"my_rig"`
MyAntenna string `json:"my_antenna"`
@@ -75,7 +76,7 @@ type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, my_iota,
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
is_active, sort_order, db_config, created_at, updated_at`
@@ -124,12 +125,12 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
res, err := r.db.ExecContext(ctx, `
INSERT INTO station_profiles
(name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, my_iota,
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
is_active, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?)`,
VALUES(?,?,?,?,?,?,?,?,?, ?,?,?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?)`,
p.Name, p.Callsign, p.Operator, p.OpName, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef, p.MyIOTA,
p.MyRig, p.MyAntenna, nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
nullableFloat(p.MyLat), nullableFloat(p.MyLon), nullableFloat(p.TxPower),
boolInt(p.IsActive), p.SortOrder, now, now)
@@ -144,13 +145,13 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
UPDATE station_profiles SET
name = ?, callsign = ?, operator = ?, op_name = ?, owner_callsign = ?, my_grid = ?, my_country = ?,
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?,
my_sota_ref = ?, my_pota_ref = ?, my_iota = ?, my_rig = ?, my_antenna = ?,
my_dxcc = ?, my_cqz = ?, my_ituz = ?, my_lat = ?, my_lon = ?, tx_pwr = ?,
sort_order = ?, updated_at = ?
WHERE id = ?`,
p.Name, p.Callsign, p.Operator, p.OpName, p.OwnerCallsign, p.MyGrid, p.MyCountry,
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna,
p.MySOTARef, p.MyPOTARef, p.MyIOTA, p.MyRig, p.MyAntenna,
nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
nullableFloat(p.MyLat), nullableFloat(p.MyLon), nullableFloat(p.TxPower),
p.SortOrder, now, p.ID)
@@ -264,7 +265,7 @@ func scan(row scannable) (Profile, error) {
var p Profile
var (
callsign, operator, opName, ownerCall, myGrid, myCountry, myState, myCnty,
myStreet, myCity, myPostal, mySOTA, myPOTA,
myStreet, myCity, myPostal, mySOTA, myPOTA, myIOTA,
myRig, myAntenna sql.NullString
myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon, txPwr sql.NullFloat64
@@ -273,7 +274,7 @@ func scan(row scannable) (Profile, error) {
createdAt, updatedAt string
)
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA, &myIOTA,
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
&isActive, &sortOrder, &dbConfig, &createdAt, &updatedAt)
if err != nil {
@@ -294,6 +295,7 @@ func scan(row scannable) (Profile, error) {
p.MyCity = myCity.String
p.MyPostalCode = myPostal.String
p.MySOTARef = mySOTA.String
p.MyIOTA = myIOTA.String
p.MyPOTARef = myPOTA.String
p.MyRig = myRig.String
p.MyAntenna = myAntenna.String
+42
View File
@@ -3002,3 +3002,45 @@ func IsFilterable(column string) bool { return filterableColumns[column] }
// column of their own and live in extras_json.
func IsBulkEditableExtra(field string) bool { _, ok := bulkEditableExtras[field]; return ok }
func IsFilterableExtra(field string) bool { _, ok := filterableExtras[field]; return ok }
// WorkedGridKeys returns the set of "GRID|MODE" keys already in the log, where
// GRID is the 4-character field+square and MODE has been through normMode.
//
// The mode is part of the key, and normMode is what makes the DX-cluster
// "group digital modes" option apply to grids for free: with grouping on, FT8
// and FT4 both normalise to the same token, so a grid worked on FT8 is not new
// on FT4; with it off they stay separate keys and it is.
//
// Truncated to four characters on the way in. A log holds a mix of JN36 and
// JN36QU depending on where each QSO came from, and grid chasing is a
// field+square game — without the truncation the same square counts as new
// forever, once per subsquare.
func (r *Repo) WorkedGridKeys(ctx context.Context, normMode func(string) string) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
// The column is "grid", not "gridsquare" — that is the ADIF field name, and
// gridsquare_ext is a different (six-plus character) column entirely.
`SELECT DISTINCT COALESCE(grid,''), COALESCE(mode,'') FROM qso
WHERE grid IS NOT NULL AND grid != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 4096)
for rows.Next() {
var grid, mode string
if err := rows.Scan(&grid, &mode); err != nil {
return nil, err
}
grid = strings.ToUpper(strings.TrimSpace(grid))
if len(grid) < 4 {
continue
}
grid = grid[:4]
mode = strings.ToUpper(strings.TrimSpace(mode))
if normMode != nil && mode != "" {
mode = normMode(mode)
}
out[grid+"|"+mode] = struct{}{}
}
return out, rows.Err()
}
+11
View File
@@ -67,6 +67,17 @@ func acquireInstance(postUpdate bool) bool {
return false
}
// processStart is stamped on the very first instruction of main, before the
// single-instance guard and before anything else runs.
//
// It exists to split a slow launch in two, because the log alone could not: the
// first line applog writes already sits inside startup(), so everything spent
// loading the binary and creating the WebView2 environment happened before the
// log begins and was invisible. An operator reporting "nothing happens for three
// seconds" was impossible to answer from a file whose first timestamp is the
// moment the app was already running.
var processStart = time.Now()
func main() {
// Single-instance guard: if OpsLog is already running, focus that window and
// exit instead of spawning a duplicate. A second process would open its own
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.24.3"
appVersion = "0.24.4"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.