Commit Graph
100 Commits
Author SHA1 Message Date
rouggy 6497568813 chore: release v0.24.3 2026-08-10 13:56:29 +02:00
rouggy 9f62808392 fix(webpub): the published page could not sort a date, and could not be unsorted
parseFloat accepts a numeric PREFIX. "2026-08-10" therefore became the number
2026, every date in the same year compared equal, and since the sort is stable
nothing moved: the Date column looked as though it were simply not sortable.
The same trap caught every callsign starting with a digit - 8B81SU and 8P9AB
both read as 8 - and the times, where "09:56" became 9. The numeric test is now
anchored to the whole cell, so anything that is not entirely a number is
compared as text, which is exactly right for an ISO date.

Sorting had no way back either. Each row now carries the index it was published
at, and a third click on a header restores that order. Headers also show an
arrow: with no indicator, a column that silently refused to sort was
indistinguishable from one that had sorted into the same order.

Blank cells sink in both directions rather than leading the ascending sort. An
empty field is missing data, not the smallest value.

Tested where it can be: the page ships its own script, so a regression is silent
- the table still renders, it just sorts wrongly.
2026-08-10 13:52:13 +02:00
rouggy 9d8b69d804 feat(cluster): mark the cell that carries the fact, and add the US county
Colour moves from the text to the CELL. A filled Band cell means new band, a
filled Pfx cell means new prefix, a filled County cell means new county. This is
not the pills coming back: a pill is a box inside the cell with its own height
and padding, so it pushed the text off the row baseline. A background has no
geometry - the text does not move a pixel - and it reads from across the room,
which a tinted glyph does not. Worked-call stays text-only: it is not a novelty,
and filling it would wash most of the rows.

The colours still come from the semantic tokens and lib/spotMarkers, so a fact
keeps one colour across the grid, the band map and the filter chips - and the
per-marker colour setting will drive all of it from one table.

US County column. The backend already resolved the county from the offline ULS
store to decide NewCounty and then threw it away; it now returns it, which costs
nothing.

The Locator column is renamed Spotter locator. It always held the SPOTTER's grid
- cluster.go says so - so a column labelled Locator next to a DX callsign was
reading as the DX's grid and was mostly empty besides. The DX grid is not in the
feed at any price worth paying under an RBN firehose.

Both display options move out of Preferences into the cluster filter panel,
beside Hide worked. They are changed while working a run, not set up once, and
a preferences dialog reopened every ten minutes is a filter in the wrong place.
2026-08-10 12:29:57 +02:00
rouggy 6825af135a fix(cluster): NEW CALL is not NEW SLOT
The slot-highlight option reused the new-slot status, which already meant
something else and something narrower: the ENTITY was worked on this band and
on this mode, but never on the two together. What the option surfaces is a fact
about the CALLSIGN - never worked on this band and mode - and the entity may be
long since confirmed there.

Overloading it cost exactly what it always costs. Forty consecutive rows read
NEW SLOT, the genuine new-slot rows drowned in them, and the Status column
stopped carrying information at all.

new-call is its own status now, with its own badge, its own tooltip spelling out
that it is the callsign and not the entity, and its own filter chip. It keeps
the caution colour for the moment: it sits at the same "worth a look, not a new
one" level as new-slot, and the colour rework will separate them.

The status filter had to move onto the displayed status too. It read the raw
backend entry, where new-call by construction never appears, so the chip would
have selected nothing at all.
2026-08-10 12:21:01 +02:00
rouggy ca42fd90f9 fix(cluster): carry worked_slot to the frontend, and react to the option at once
The "colour the stations not worked on this band and mode" option did nothing.
The backend computed WorkedSlot correctly and the generated bindings declared
it, but the status map in App.tsx is assembled field by field from the reply,
and worked_slot was not on that list. The extra key was simply dropped, so the
frontend saw undefined, the === false test never matched, and every spot stayed
muted. Silent by construction: nothing warns that a copied-out struct is missing
a field. All three assembly sites now carry it, and the state type names it so
the compiler catches the next one.

The band map also memoised the transformed status map on spotStatusRaw alone,
so toggling an option changed nothing until a poll happened to hand over a fresh
object. The options are read at render time and are part of the dependencies.
2026-08-10 12:13:16 +02:00
rouggy 18a583901c fix(bandmap): a muted spot must be grey, not brown
Muting emptied the status, which dropped the spot onto statusStyle's default
branch. That branch paints bg-primary/60 - the theme's burnt orange at 60% over
a dark card, i.e. brown - and it was written for the rare unresolved entity,
where a hint of the accent colour is right because the operator should look.

With the mute option on, most of the map is muted. So the whole band map turned
brown and the spots that were meant to stop competing for attention were the
only ones tinted with the app's own "look here" colour.

Muted now lands on QUIET_STYLE: card background, grey border, grey accent bar
and leader. That is the same style the 'worked' status already used, so it is
now one constant rather than two copies - a muted spot and a worked spot are
the same statement and must not drift apart.
2026-08-10 12:06:52 +02:00
rouggy 2cb1add3db fix(cluster): let the two display options compose, and stop claiming a station was worked
Three defects in the options added in the previous commit, all found on air.

Order. Mute returned early, so with both options on the mute swallowed exactly
the spots the slot option existed to surface: the mute test is entity-level, and
an unworked callsign inside a worked entity passes it. Slot promotion now runs
first, and protects itself for free since bringsNothingNew is false on new-slot.
The two were documented as composing - "mute what is done, light up what is
not" - and they did not.

Empty status. Muting emptied the status, but an empty status already meant
"entity not resolved" in the band map, so every muted spot claimed its entity
was unknown with the country printed two words earlier in the same tooltip. A
muted flag now records WHY it went empty; blanking still drives the colour, the
badges and the ranking.

Wording. The muted tooltip said "already worked" of a station never worked -
LZ8NG on a 421st Bulgarian. What is worked is the ENTITY on this band and mode,
so that is what it says now.

new-mode had no case in the band map's statusLabel and fell through to "entity
not resolved" too. Pre-existing, same one-line switch.
2026-08-10 12:01:31 +02:00
rouggy a3815c24a1 feat(cluster): quiet the worked spots, light up the empty slots
Two options that change what the eye is pulled towards, both applying to the
cluster list AND the band map.

Mute worked before: a spot that brings nothing new loses its colour and its
badges. It stays in the list - the operator asked for less noise, not less
information. "Brings nothing new" reuses the dimming rule the cluster list
already had rather than inventing a second notion of done, so it keeps obeying
the same-slot option and the digital-mode grouping for free. A new-band or
new-slot status is NOT muted: having worked that callsign once on another band
says nothing about the band in front of you.

Highlight unworked in this slot: colours any callsign not yet worked on this
band and this mode, whatever the entity says. For an operator filling slots a
common entity on a fresh band+mode is the whole point, and the entity-level
status flatly calls it worked. It reuses the existing new-slot status, so no new
colour, no new legend, no new badge - both panels already knew how to draw it.

WorkedSlot is computed independently of the same-slot preference: it is what
this option reads, and it must not change meaning because a different option was
toggled. The slot index is now built when either option needs it, and the status
cache is keyed on both so a toggle invalidates it.

The rules live in one module used by both panels. Marker colours already taught
us what happens when the two derive the same thing separately.
2026-08-10 11:44:21 +02:00
rouggy f6f5235a8b feat(bandopen): announce sporadic-E openings on 6, 4 and 2 m
Observation, not prediction, and it needs no new data source: the cluster event
worker already enriches every spot with the great-circle distance and bearing
from the operator's grid, which is exactly what a single-hop Es detection rests
on.

The signature is four or more DISTINCT stations at 500-2400 km inside a 90
degree bearing sector within twelve minutes. Each constraint earns its place:
distinct callsigns because one station spotted by six skimmers is six spots and
one station; the lower bound because a 6 m contact under 500 km is ordinary
tropo and says nothing about the ionosphere; the upper bound because past one
hop the bearing test stops meaning anything; and the sector because a real Es
cloud illuminates a direction, which is what separates an opening from a merely
busy evening.

Fed AFTER the Historical guard in the worker. A SH/DX reply replays a hundred
past spots in a second - precisely the shape of a burst - and would announce an
opening that ended hours ago.

Season LABELS, it never gates. Both hemispheres get a summer peak and a lesser
winter one, and an opening outside those is announced with "unusual for the
season" attached: the rare one is the one an operator must not hear about last.

One announcement per band per opening (45 minute quiet period). An opening runs
for hours and produces hundreds of spots; one alert is information, forty is
noise.
2026-08-10 09:49:22 +02:00
rouggy 86a644863a chore(changelog): open an empty 0.24.3 block
Opened as soon as 0.24.2 is tagged, so the next change has somewhere to go and cannot end up described under a version that already shipped - which is exactly what happened to 0.24.1. Invisible until the build reports 0.24.3: GetChangelog drops every entry newer than appVersion.
2026-08-10 09:25:16 +02:00
rouggy dd9fddbc5b chore: release v0.24.2 2026-08-10 09:23:29 +02:00
rouggy 74acf88976 chore(changelog): move the post-release entries to 0.24.2
v0.24.1 was tagged and shipped mid-session; the six commits that followed kept appending to its block, so the released version appeared to describe changes it does not contain. 0.24.1 is restored to exactly the five entries that shipped - verified against git show v0.24.1:changelog.json - and the five later ones open 0.24.2.
2026-08-10 09:23:27 +02:00
rouggy da9c76e161 feat(icom): offer the bands the connected radio actually has
The band row was hardwired to 160-6 m, so an IC-9700 - the one radio in the range with no HF at all - showed ten dead buttons and none of 2 m, 70 cm or 23 cm. bandsFor(model) follows the model, exactly as attOptions already did for the attenuator steps. bandOfHz's labels had to move with it: it returned '70' where the button says '70cm', so the current-band highlight could never have matched, and it knew nothing of 23 cm.
2026-08-10 09:07:08 +02:00
rouggy fb78b1c052 feat(webpublish): publish the log as a web page or CSV, with FTP upload
Settings -> Web publishing. Renders the last N QSOs with the columns the
operator picks, writes the file locally, and optionally uploads it by FTP or
explicit FTPS.

Local write FIRST, upload second, always. A network failure then leaves a good
file on disk that can be published another way, instead of a truncated one on
the server. The local write itself goes to a temp file and renames over the
target, so a reader — or a syncing client — never sees a half-written page.

The HTML page is fully self-contained: inline CSS, inline sort script, no font,
no CDN, no external request at all. It has to work on hosting that blocks
third-party requests, and a page about someone's hobby should not report its
readers to anyone.

Columns are a curated set, not "every ADIF field". This is published to the
public: RST and QSL status belong on it, the operator's home address does not.

Two triggers, both debounced through one path: a QSO is logged, or the optional
timer fires. Fifteen seconds of coalescing means a run of contacts produces one
upload rather than one per QSO, and nobody reading a web page can tell the
difference.

The config is one JSON blob under a single settings key, and that key is marked
sensitive: the FTP password lives inside it, so the whole blob is encrypted at
rest with the others. A locked vault reads back empty, which correctly reads as
"not configured" — publishing must not run with a password it cannot decrypt.
2026-08-10 09:07:08 +02:00
rouggy e51abd262e fix(bandmap): refresh spot colours when the band map is the visible panel
The post-QSO status refresh was gated on the DX-cluster list being on screen; the band map draws the same statuses and was left out. Working a station in the ordinary layout - Recent QSOs left, docked band map right - left its pill NEW until the cluster tab happened to be opened. Reported on an E51 that stayed orange with the QSO already in the log.
2026-08-10 08:51:06 +02:00
rouggy 0ec855d95a fix(map): stop using OpenStreetMap's volunteer tile servers
OSM blocked OpsLog and every user's map filled with "Access blocked / 403"
tiles at once. Their policy is explicit: an application must identify itself
with a proper, unique User-Agent on every tile request. A program that draws
its maps inside a web view cannot do that — the browser sets that header and
Leaflet fetches tiles as plain <img> loads — so this is not something to tune,
it is a service we are not entitled to use.

Two layers still pointed there: the Street basemap and the locator map. Street
moves to Esri World Street Map, the locator map to Carto, both key-free and
both under terms that cover a redistributed application. OpenStreetMap keeps
its credit: Carto's tiles are built from OSM data.

The locator map also gains the tile restraint the world map already had —
updateWhenIdle, no fetching mid-zoom, one ring of buffer. Fetching as fast as
the pointer moves is another item on OSM's block list, and the replacement
providers are under no more obligation to tolerate it than OSM was.

The basemap preference key is untouched, so an operator who had picked "Street"
still gets a street map, from the new source.
2026-08-10 08:51:06 +02:00
rouggy ae06495f91 feat(spots): give a new county its own colour, from a shared marker table
A new county and a new park both drew --success, so the two were the same green
in the cluster list and, since the band map copied it, in the band map too.
County takes violet; POTA keeps green, the association being worth something.

Violet is a chart hue rather than a semantic token because every token was
already spoken for: red, orange and yellow are the entity statuses, blue is
"callsign already worked", green is now POTA. It is defined in both the light
and dark chart groups, so all eleven themes have it.

Changed in BOTH panels at once — separating them in one place would have left
the two views contradicting each other about the same fact, which is worse than
sharing a colour. To make that impossible to get wrong again, the marker
definitions move to lib/spotMarkers: key, colour and label in one table that the
cluster list and the band map both read. That table is what a per-marker colour
setting will drive, which is why it is a table and not three constants.

The band map deliberately shows three of the four markers. A new PREFIX stays a
cluster-list badge: the pill is 22 px tall and a fourth segment on its strip
turns it into a colour code nobody reads at a glance.
2026-08-09 21:12:28 +02:00
rouggy 4dd2c3b997 feat(bandmap): show new POTA, new county and worked-callsign
The band map coloured only the entity status and dropped the other three
markers on the floor — not for want of data: they were already in the status
entry it receives, the local type simply never declared them, so a new park on
an entity you have worked was indistinguishable from any other worked spot.

Colours are the DX-cluster list's, taken from it rather than chosen: --info for
a worked callsign, --success for a new county and a new park. The same fact must
not be blue in one panel and green in the next.

They STACK, they do not replace. These markers are orthogonal to the entity
status — worked entity plus new park is an ordinary combination — and the
cluster list already spells them out side by side rather than letting one win.
So the pill keeps the status colour and the markers take its left strip, split
into one segment each. That strip previously repeated the pill's own colour and
carried no information at all, which is what made it the right place.

Note that a new county and a new park share --success in the cluster list, so
they share it here too. Splitting them means changing both views together, which
is the moment to do it — when the per-marker colour setting arrives.
2026-08-09 21:01:54 +02:00
rouggy ec71dd1661 chore: release v0.24.1 2026-08-09 17:02:13 +02:00
rouggy 31f9bdfc98 fix(qsolist): let the Max box be lowered
The input was bound straight to the number and committed on every keystroke, so
it could not be EMPTIED: clearing it yields "", Number("") is 0, 0 fails the
"> 0" guard, the state never moved, and value={qsoLimit} snapped the old figure
straight back. Going from 200000 down to 100 was a fight against the field — and
read as the setting refusing to persist, which it was not: writeUiPref stores it
and syncPortablePrefs makes the database authoritative at boot. It also wrote
the preference once per keystroke (1, then 10, then 100).

Raw text in local state, committed on blur or Enter, per the controlled-input
note in CLAUDE.md.
2026-08-09 17:00:46 +02:00
rouggy 57e98139ab perf(awards): release the logbook snapshot once it goes cold
Opening the Awards panel pulls every QSO into a cached slice and kept it for the rest of the session: the cache was only ever invalidated by a logbook change, never by disuse. Each cached QSO is a 1896-byte struct plus its strings AND a decoded map of its ADIF extras - one map allocation per QSO. Harmless at 30k rows, several hundred megabytes at 132k, which is where it was reported. A janitor drops it after 15 minutes without a reader and calls FreeOSMemory, because Go hands pages back lazily and the whole point is that the operator sees the memory return. 15 minutes is deliberately generous: an awards session recomputes every few seconds and re-pulling a large remote logbook costs seconds. The heap size is now logged when the snapshot is built and when it is released - a memory report was unanswerable without a number.
2026-08-09 16:57:12 +02:00
rouggy b10a867125 perf(cluster): batch the console lines instead of one render per line
Every line of cluster traffic went straight to state: a spread copy of a 2000-element array, a slice copy, and a React render, per line. An RBN feed sends hundreds a second, so that was tens of MB/s of garbage in the renderer and hundreds of re-renders - and it ran whether the console was open or not, so the cost was paid for a panel nobody was looking at. Reported on an old PC where the UI had stopped responding. Lines are staged in a ref and flushed on a 200 ms timer, the same shape the spot handler already uses. The staging buffer is bounded too, so a burst longer than the console can show is not carried in full just to be sliced away on commit.
2026-08-09 16:48:33 +02:00
rouggy 17819ea673 fix(update): survive an exe that cannot be renamed
Several operators hit "stage current exe: rename …\OpsLog.exe …\OpsLog.exe.old:
Accès refusé" and could not update again. Two separate causes, both ours to
handle.

The staging name was fixed. os.Rename replaces its target, so a single leftover
".old" that could not be deleted — a scanner holding it open is the usual
reason, and the pre-existing os.Remove was best-effort and ignored — made every
later update fail with that error, permanently, recoverable only by deleting the
file by hand. Staging now uses a unique ".old-<nanos>", which no leftover can
block, and the startup cleanup sweeps the pattern instead of one name.

Renaming a running image is legal on Windows, but some endpoint protection
(Bitdefender's ransomware remediation among them) blocks it outright, and no
retry gets past that. So the swap is deferred: the new build is parked beside
the old one and a detached helper moves it into place after this process exits,
when the file is no longer a running image. It keeps trying for ten seconds,
since a scanner tends to let go a beat after the process dies.

A short retry stays in front of both, for the ordinary case of a scanner holding
the file it has just watched being written.

If even the deferred move fails, OpsLog restarts on the CURRENT version rather
than leaving the operator with nothing — someone mid-QSO losing their logger is
worse than an update that waits — and only a successful swap passes
--post-update, so the download survives for the next attempt instead of being
swept by the cleanup.
2026-08-09 15:38:50 +02:00
rouggy a8fac52400 feat(bandmap): drag the width, and remember it
Both band maps were pinned to a hardcoded width — 300px docked beside the
tables, 260px per card in the Band map tab. On a busy band the map could not be
given more room, and on a quiet one the log could not take it back.

The docked map becomes a resizable grid column with the grip in the gap between
the panes, so the handle costs no space; the tab cards share one width with the
grip on their right edge. Side-by-side columns of different widths read as a
mistake rather than a choice, which is why the tab has one width and not one
per card. Double-click either grip to return to the default.

The drag measures from the pointer's START position rather than the container,
so the same helper serves both edges — the docked map sits on the left or the
right depending on the operator's setting, and the grip is on its inner edge
either way. Pointer capture, like the main splitter: without it the map or the
grid under the cursor swallows the moves.

Both widths are persisted through writeUiPref and registered as portable, so
they travel with the data folder like the main splitter and the rest of the
layout.
2026-08-09 13:12:43 +02:00
rouggy 2bf98a9801 chore: release v0.24.0 2026-08-09 13:01:59 +02:00
rouggy 80dac64b56 feat(theme): four themes with an actual hue — Indigo, Ocean, Plum, Nordic
The seven existing themes are warm beige, cool grey, sage grey, slate, warm
dark, graphite and black — every one neutral, six of the seven accented orange.
Picking a theme changed the shade of grey and little else.

These colour the SURFACES, not just the accent, and each takes a different
primary so the picker tells them apart at a glance: violet on deep indigo, cyan
on deep teal, magenta on aubergine, indigo on a crisp cool white.

The semantic colours stay recognisable as themselves. A logger is read for
hours and "red means a problem" cannot become a decorative choice, so the hue
budget went on the surfaces and the primary. Where a theme's primary would have
collided with a meaning, the MEANING kept its identity and the ornament moved:
Ocean's info is blue rather than cyan, Plum's danger is red rather than rose,
and the matrix entity ramp shifts to cyan under Indigo and teal under Nordic so
a "confirmed" cell never reads as a button.

Everything else follows for free: the grids resolve var(--…) at runtime, so
they re-skin with no re-render. The three shared registrations that do NOT
follow automatically are done — the chart palette (light vs dark ramp) and the
date-picker icon inversion, which is keyed on an explicit list of dark themes.
2026-08-09 10:06:05 +02:00
rouggy 79427ccd18 fix(awards): apply the reference Prefix where it can actually help
Prefix exists so a field holding a bare value counts for an award whose codes
carry a letter: a French operator writes "74" in STATE, DDFM's codes are "D74".
Def's own doc says exactly that. But searchOne applied the prefix AFTER looking
the token up in the reference list — after the step that had just failed — so
the bare form matched nothing and the prefix decorated an empty result. The only
way through was a regex, in a mode where the operator had chosen "code" and
explicitly not "pattern".

The token lookup now tries the prefixed form when the bare one is not a known
reference. The list stays the authority: an unknown number still matches
nothing, so the prefix completes references rather than inventing them.

Second bug in the same pass: the blanket prefix also hit codes that came
straight OUT of the list, so a field already holding "D74" produced "DD74" as
soon as a prefix was configured — for both the token lookup and the
description matcher. Branches that yield whole codes are now excluded from it;
the ones that yield a raw capture (regex, whole-field split) still get it.
2026-08-09 09:43:27 +02:00
rouggy 4352b9aec5 fix(udp): read WSJT-X packets that arrive through a relay
A forwarder (W&P, seen in the field in front of MSHV) prepends the origin as
plain text before re-broadcasting:

    "127.0.0.1:2237|" + <the original, untouched WSJT-X packet>

That puts the magic 15 bytes in, so every datagram failed on "bad magic
0x3132372e" — those four bytes being ASCII "127." — and an operator running
MSHV behind the relay saw no decodes, no callsigns and no auto-logged QSOs.

No new service type: what follows the header IS a WSJT-X packet, so the parser
and everything downstream apply unchanged, and a separate type would duplicate
decode, status and logged-ADIF handling to strip 15 bytes. ParseWSJT skips the
header instead, which also covers any other relay that wraps traffic this way.

The match is deliberately narrow — the magic must fall within the first 64
bytes AND every byte before it must be printable ASCII. A corrupt or truncated
packet that merely contains those four bytes somewhere is not resurrected into
a QSO; it fails exactly as it did before.

Test data is the real captured datagram, header included.
2026-08-09 08:06:21 +02:00
rouggy a0f7f2abf0 feat(udp): show the packet behind a parse error, and stop repeating it
"WSJT parse error: bad magic 0x3132372e" named neither the sender nor the
payload, so there was nothing to act on — even though those four bytes are
ASCII "127.", i.e. some program broadcasting an address on a port expecting
WSJT-X binary.

The line now carries the remote address, the size, a printable preview and a
hex dump of the first 96 bytes. Text senders are readable at a glance; a
genuinely binary payload still shows its bytes.

And it stops after five. The reported case wrote that line about 150 times a
second: a permanently misconfigured port would fill the 10 MB rotating log with
one repeated sentence and bury every other piece of evidence — the log's whole
purpose. The fifth line names the two things worth checking, the sender and the
service type.

N1MM's parse error goes through the same path; it had no packet detail either.
2026-08-09 07:59:51 +02:00
rouggy 40f5960c76 feat(awards): implement the QRZ.com and Custom confirmation sources
The award editor offered five confirmation sources and Def's own doc comment
named five, but confirmed() had cases for three. "qrzcom" and "custom" fell
through the switch, so ticking either marked nothing as confirmed — the exact
failure the GrantCodes comment in this struct warns about: a checkbox that does
nothing is worse than no checkbox, because it is trusted.

QRZ.com reads qrzcom_qso_download_status, not the upload one: uploading a QSO
is us telling QRZ about it, which confirms nothing.

Custom names a field. Rather than a checkbox per external source, the Def gains
ConfirmField + ConfirmValue: any QSO field or ADIF extras key, and optionally
the comma-separated values that count. That one shape covers the three cases
asked for — the OpsLog card marker (APP_OPSLOG_QSL_RCVD), an arbitrary ADIF
tag, and a tag stamped by an imported club list — because all three end up as a
field on the QSO.

An empty ConfirmValue means any non-empty content confirms: the OpsLog marker
stores the date the card arrived, not a Y/N flag. A custom source naming NO
field confirms nothing, deliberately — the opposite default would silently mark
a whole logbook confirmed.
2026-08-09 02:11:24 +02:00
rouggy 59135d55ab fix(window): don't save the geometry of a minimised window
Windows parks a minimised window at -32000,-32000 with a stub size and reports
it as not-maximised, so closing OpsLog from the taskbar while minimised stored
exactly that. Seen in a log: "window: saving -32000,-32000 237x39
maximised=false".

The restore side already rejects both the impossible corner and the
below-minimum size, so nothing opened off-screen — but it fell back to the
default placement, and the operator silently lost the size, the position and the
maximised state they had set.

saveWindowState now keeps what was already stored when the window is minimised.
Two tests for that: the Wails flag, and the -32000 corner, because a window that
is mid-close can sit at that corner with the flag already cleared. A poisoned
window.json repairs itself on the next close of an on-screen window.
2026-08-09 01:47:45 +02:00
rouggy 44cf5954fd fix(qsl): print "TNX QSL" rather than "TNX" on the card
The stamp answers "PSE QSL", so it reads as its counterpart or not at all: a
bare "TNX" next to a QSL message says thanks for something unnamed.

Changed at the source of the {qso.pse_tnx} token, so every card picks it up
with no template edit. The live indicator in the QSO editor and the hint beside
it follow.
2026-08-09 01:39:50 +02:00
rouggy f1b7a7e477 fix(qsl): put the OpsLog card in the Manage Confirmation picker too
The previous commit added its row to the status table but left the picker
listing only the column-backed channels, so the one channel you could actually
tick was the one you could not select.

Selecting it swaps the left panel for an OpsLog editor instead of the generic
sent/received/date grid, which has no fields to bind to here: Sent is shown
read-only (OpsLog stamps it when the card goes out), Received is the tick, and
the PSE QSL / TNX indicator sits next to them since that stamp is what the flag
is for.
2026-08-09 01:23:52 +02:00
rouggy 19ae00124f feat(qsl): show the OpsLog card with the other confirmations
The OpsLog QSL marker sat under QSL Msg in "Contact's details", nowhere near
the channel it belongs to, while the QSL Info tab listed every other
confirmation — QSL, LoTW, eQSL, QRZ.com, Club Log, HRDLog — with a Sent and a
Received column.

It now has its own row in that table and the "QSL received" tick moved to the
same tab, keeping the PSE QSL / TNX indicator that is the reason the flag
exists at all: received prints TNX on the card, otherwise PSE QSL.

Written by hand rather than added to CONFIRMATIONS: that table maps QSO
columns, and this channel is backed by ADIF extras (APP_OPSLOG_QSL_RCVD, plus
the older APP_OPSLOG_QSL_CARD_SENT for the sent side). Sent stays read-only —
OpsLog stamps it when a card actually goes out, and a hand tick would record
something that never happened.
2026-08-08 23:28:26 +02:00
rouggy 642ed358c2 feat(worked): fold portable callsigns into the worked-before history
Typing RK3DWA found nothing while RK3DWA/3 found 21 QSOs, so a station's
history was only visible if you happened to type the exact form it had been
logged under — and an operator who worked it as /0, /P or /MM saw none of it.
The other RDA tools and Log4OM fold these together; this does too.

The predicate strips the suffix from what was typed and matches "call = base OR
call LIKE base/%", so it works from either end: the base call finds the portable
QSOs and a portable call finds the plain ones. Deliberately not a bare prefix
LIKE 'RK3DWA%', which would also match RK3DWAB — a different station. The '/' is
what makes it the same operator.

Settings -> General to turn it off. Default ON, hence the inverted storage: an
existing install has no key, and reading that as OFF would leave everyone with
the behaviour we were asked to change.

Contest dupe checking is untouched — it runs through ContestDupe, a separate
binding, and stays an exact match as a contest requires.
2026-08-08 23:24:23 +02:00
rouggy 1d8cd25205 chore: release v0.23.9 2026-08-08 20:44:32 +02:00
rouggy 1f55cfe9fa feat(cluster): self-spot on the master node while logging
Settings -> DX Cluster: a toggle and an interval. When it is on, logging a QSO
announces the station on the master cluster — the QSO's own station callsign as
the DX, on the frequency the contact was made on — so callers find the run
without waiting for someone else to spot it. The node fills the DE field from
the login, so the spotter is us too: a self-spot.

It fires on the FIRST QSO of a frequency and then at most once per interval.
Both halves matter: announcing every QSO would flood the node and get the
station filtered out, while a pure timer would stay silent for minutes after a
band change. A drift of up to 500 Hz still counts as the same run, so nudging
the VFO mid-pileup does not re-announce.

Five minutes is the floor, clamped in SaveSelfSpotSettings as well as in the
input: the limit protects the node from us, so it must not depend on the
frontend. The interval input keeps raw text and clamps on blur — clamping per
keystroke rewrote "10" to "5" as soon as the "1" landed.

Wired into both log paths (manual entry and UDP auto-log) on the async side, so
a cluster that is slow or down never holds up logging. A send failure restores
the previous throttle state, so the next QSO retries instead of sitting out an
interval that produced no spot.
2026-08-08 20:34:10 +02:00
rouggy 90c6458af0 fix(rigctld): never leave the rig keyed when the client goes away
The Kenwood/Elecraft backend deliberately suspends its wire poll while PTT is
held — a K3 answers "?;" to IF; during transmit, and treating that as a fault
used to drop the whole CAT link. The consequence was that nothing watched the
transmitter: a client that crashed, was closed, or simply had its socket shut
under it left the rig on air.

And shutting the socket is routine. reloadCATShare tears the sharing server
down and rebuilds it on every settings save, so a Save while WSJT-X held PTT
was enough. A K3 operator's log shows exactly that: "TX;" at 17:53:09, no "RX;"
ever, the poll silent, and the rig still keyed 29 s later when the CAT link
happened to be rebuilt.

The server now drops PTT when a connection ends and when Stop() is called.
Stop() runs before reloadCAT restarts the backend, so the unkey still reaches
the radio. An atomic Swap keeps it once-only across the two paths.
2026-08-08 20:19:00 +02:00
rouggy 18f44a5aa3 fix(cat): read MD6 back as DATA on an Elecraft, not RTTY
MD6 is FSK on a Kenwood and DATA on a K3/K4. kenwoodModeToADIF decoded the
digit unconditionally as RTTY, so OpsLog contradicted the mode it had just
set: SetMode writes MD6 for a digital mode on an Elecraft, then ReadState
parsed the IF frame back as RTTY.

A K3 running FT8 therefore showed RTTY in the status bar, logged its QSOs on
RTTY, and — through the shared CAT server — told WSJT-X/JTDX the rig sat in
RTTY while they had just asked for a data mode. Found in a K3 operator's log:
every cat:state line read mode=RTTY on 21.074 FT8, two lines after OpsLog's
own "MD6;".

The digit now resolves to the configured digital mode whenever MD6 means DATA
on this rig — the Elecraft backend, and the "DATA A - MD6" data-mode option
that exists for it. A plain Kenwood still reads MD6 as RTTY.
2026-08-08 20:13:45 +02:00
rouggy 4d13cf7d15 feat(entry): normalise Name/QTH/Comment/Note case, upper-case the Recent QSOs search
One station reaches the log SHOUTED by QRZ, lower-cased by a hurried
operator and in whatever case an imported ADIF carried, so the same name
appears three ways across a log. Name and QTH are now title-cased word by
word; Comment and Note only get a capital first letter, because the rest
routinely holds callsigns and modes ("TNX QSO F5ABC, FT8 59") that
lower-casing would destroy.

Normalised on blur, never per keystroke — rewriting the value mid-word
fights the typist (the controlled-input trap in CLAUDE.md). Applied in
both entry layouts and in the QSO editor, since leaving the editor alone
would just reintroduce the mess on the first correction.

The Recent QSOs box only ever searches callsigns, so it upper-cases as
you type and carries an inline clear button.
2026-08-08 17:37:09 +02:00
rouggy 366d9df634 fix(grid): keep the award columns' position in the saved layout
Award columns were filtered out of the persisted column state entirely,
so applyColumnState({applyOrder:true}) had nowhere to put them and AG
Grid appended what the state does not mention. A column the operator
dragged to sit AFTER an award column therefore jumped back in front of
it on every reload.

Only the `hide` property is dropped now. Visibility stays owned by the
awardShown code-set — that is what stopped shown award columns from
vanishing on an awardCols rebuild — while the position survives the
round-trip.
2026-08-08 17:36:48 +02:00
rouggy f4b7c9dcf8 fix(settings): restore the settings-database buttons and unstack the backup options
The Settings -> Database panel had lost every action on the settings
database except "Open folder", which launches Windows Explorer — where a
.db cannot be selected at all, so double-clicking it only asks which
program should open it. The handlers were still there, just no longer
rendered; New / Open existing / Save a copy / Rename / Reset are back,
along with the restart banner (the DB pointer is only read at startup).

The backup block put the rotation field and three checkboxes on one flex
row inside max-w-xl. That fitted until "keep every backup" arrived with
its two-line hint, after which every label wrapped into an unreadable
sliver. The options are stacked now, and keep-all sits under the option
it depends on.
2026-08-08 17:36:31 +02:00
rouggy 48c448e6f3 chore(bindings): regenerate the Wails TS bindings
Stale since the 0.23.9 work: SetOpsLogQSLReceived, the backup keep_all
flag, the award PRIMARY search prefix and the default QSL message were
all missing from the generated bindings.
2026-08-08 17:35:53 +02:00
rouggy 9c757e5175 feat(cat): distinct Elecraft K3/K4 backend in the rig list
The Elecraft handling was buried in the Kenwood "data mode" option, so users
couldn't find it. Add "Elecraft K3/K4" as its own entry in the CAT backend
selector. It reuses the Kenwood-dialect transport and the Kenwood USB/network
settings (the K3 emulates the Kenwood command set — a separate transport would be
a near-total duplicate), with an `elecraft` flag on the client that forces
digital modes to DATA A (MD6+DT0) — no data-mode dropdown needed. RigState still
reports Backend "kenwood" so the CW-over-CAT keyer capability keeps working.
2026-08-08 14:15:55 +02:00
rouggy 626edca8ba feat(cat): Elecraft K3/K4 DATA A — send MD6 + DT0 on data mode (from the stale branch)
Integrates the fix/jtdx-kenwood-tx work onto main: the Kenwood/Elecraft backend's
"data" data-mode path now sends MD6 then DT0 so a K3/K4 lands in DATA A — the
audio sub-mode FT8 uses. MD6 alone could leave the rig in an FSK/PSK sub-mode
where it keyed but the rear sound-card audio never modulated ("transmits but
nothing comes out"). The data-mode option is relabelled "DATA A — MD6+DT0
(Elecraft K3/K4)". A shared Kenwood/Elecraft backend is kept (the K3 emulates the
Kenwood dialect); the Elecraft specifics live behind the data-mode seam rather
than a 7000-line duplicate backend.
2026-08-07 16:37:55 +02:00
rouggy b202d98ae5 fix(awards): add a Prefix field to the primary QSOFIELDS search
Only OrRule had a Prefix ("prepended to each found reference", e.g. postal 74 →
D74); the primary search passed "" for prefix, so an award whose references are
D01/D02… couldn't prepend the D on its main rule — only on OR fallbacks. Add
Def.Prefix, pass it into the primary run(), and expose a Prefix input next to the
primary Leading/Trailing fields in the award editor.
2026-08-07 14:23:41 +02:00
rouggy 949cc17ed1 feat(backup): "keep every backup" option — timestamped files per exit
Sub-option of "back up on every exit": when on, backup.Run/RunADIF stamp the
filename with the time (opslog-YYYY-MM-DD-HHMMSS.db) so each run is a distinct
file instead of overwriting the day's snapshot. Rotation still trims to the
newest N (raise Rotation for a longer history). Threaded as a `unique` flag
through runConfiguredBackup/backupLogADIF from BackupSettings.KeepAll
(keyBackupKeepAll); UI sub-checkbox shown only when EveryExit is on.
2026-08-07 12:28:52 +02:00
rouggy e9feeffdc3 fix(udp): forward WSJT/JTDX/MSHV-logged QSOs to the outbound ADIF integrations
autoLogFromUDP (the inbound WSJT-X/JTDX/MSHV log path) inserted the QSO but,
unlike the manual LogQSO path, never called a.udp.EmitLoggedADIF — so a QSO
received from MSHV was not re-emitted to the outbound ADIF listeners (Log4OM,
N1MM, GridTracker…). Emit it in the same async goroutine. A pathological
self-loop is broken by the existing ±2-min dedup, which returns before the emit.
2026-08-07 10:56:49 +02:00
rouggy 7d7d1042c0 feat(qsl): OpsLog QSL received marker + PSE/TNX card stamp + default QSL message
Received flag: new APP_OPSLOG_QSL_RCVD extra, toggled on the QSO edit window
next to QSL Message (immediate targeted write via SetOpsLogQSLReceived so it sets
AND clears reliably), plus a live PSE QSL / TNX indicator and a Recent-QSOs
column mirroring the sent one.

PSE/TNX card stamp: automatic — received → TNX, otherwise PSE QSL — exposed as
the {qso.pse_tnx} token (added to qslVars, so preview and send agree) and placed
in the default QSO-box footer; it can be moved to its own element in the designer.

Default QSL message: new qsl.default_message (QSLEmailTemplates.DefaultMessage),
edited under Settings → E-mail → QSL card e-mail. qslVars falls back to it when
the QSO's own QSLMSG is empty, so a per-QSO message always wins. Single choke
point covers both live preview and send.
2026-08-07 10:36:57 +02:00
rouggy 9cbfd39da0 ui(cluster): drop the value-looking placeholders in My rig/antenna/satellite
'Flex 8600', 'UB640', 'AO-91', 'U/V' read as real values, so an empty field
looked already filled. Removed so an empty field is visibly empty.
2026-08-07 09:53:01 +02:00
rouggy 5338ad0b29 feat(backup): option to back up on every exit, not just once a day
New BackupSettings.EveryExit (keyBackupEveryExit) with a Settings → Backup
checkbox. When on, the three shutdown-backup gates (plannedShutdownSteps,
runBackupForShutdown, maybeShutdownBackup) bypass the HasBackupToday /
HasADIFBackupToday "already done today" check and always run, so a second
session's QSOs are captured instead of skipped until the next day. The dated
backup file for today is refreshed (overwritten) each exit; prior days are still
kept by rotation.
2026-08-07 09:46:23 +02:00
rouggy 970127cc16 changelog: open empty 0.23.9 block 2026-08-07 00:17:42 +02:00
rouggy 3704dbdd99 chore: release v0.23.8 2026-08-07 00:16:56 +02:00
rouggy 3cdf16b501 perf(cluster): throttle the grid refreshCells to 200ms (coalesce RBN status bursts)
spotStatus updates ~20x/second under an RBN firehose; firing a full refreshCells
on each was pure churn on a slow PC. Coalesce into one refresh per 200ms. Keeps
the NEW/WORKED badge colours and the 'represents nothing' dimming — the dimming
itself is a trivial pure read now that the worked-index scan is cached, so it's
no longer a cost worth removing.
2026-08-07 00:13:23 +02:00
rouggy 9a72afd467 perf(cluster): cache the worked-index and bound the spot-status cache (RBN firehose)
A user on a slow PC with RBN saw OpsLog at 94% CPU and 8.5 GB RAM (Logger32: 3%
/ 34 MB on the same feeds). Two runaway costs under the spot firehose:

- ClusterSpotStatuses re-scanned the ENTIRE logbook (5-6 full-table maps) on
  every 50 ms spot batch — ~20×/second — its "one scan regardless of batch" doc
  was untrue. On a big log that's millions of row-scans/second → the pegged CPU.
  Now cached in clusterStatusCache (an immutable snapshot), rebuilt only when the
  logbook changes (noteWorked on a single log, invalidateAwardStats on bulk), so
  it's one scan per logged QSO instead of per batch.

- The frontend spotStatus map had no cap: one entry per call|band|mode ever seen,
  and RBN produces thousands of unique calls/hour → unbounded growth to GBs, plus
  a full {...prev} copy 20×/second. Now pruned back to the live (SPOTS_CAP=1000)
  spots once it drifts past 2×, with a cheap same-reference bail-out otherwise.
2026-08-07 00:08:24 +02:00
rouggy e2bfe73bdb fix(backup): back up the contacts (logbook), not just the settings db
Since the logbook was split into its own SQLite file, backup.Run was still
snapshotting a.db (the settings/config database) — so the scheduled and manual
backups silently stopped including the QSOs. The operator was backing up config
and thinking it was their log.

Track the resolved logbook file path (a.logDbPath, set in connectLogbook) and
route the backup through a new runConfiguredBackup: the CONTACTS become the
primary "opslog-*" backup (the logbook file on SQLite, an ADIF export on MySQL),
and the settings/config db is snapshotted separately as "opslogcfg-*" so nothing
is lost. backup.Run takes a name prefix; the two sets rotate independently.
2026-08-06 23:22:04 +02:00
rouggy 6e2e2cc3aa changelog: move the recording-email editor to 0.23.8 (0.23.7 is released) 2026-08-06 19:11:10 +02:00
rouggy 75d11a4069 feat(email): editable subject/body for the QSO recording e-mail
The recording e-mail's subject and body round-tripped through EmailSettings and
the backend (keyEmailSubject/keyEmailBody) but had no UI editor — only the QSL
card e-mail did, so the recording mail was stuck on the default text. Add the
same subject/body editor to the E-mail panel, above the QSL block, sharing the
{CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} template variables.
2026-08-06 18:14:24 +02:00
rouggy cfe5d6f2a7 chore: release v0.23.7 2026-08-06 18:06:04 +02:00
rouggy 78c289d681 changelog: add Russian District Award (RDA) 2026-08-06 18:05:38 +02:00
rouggy 761f554ac0 fix(kenwood): keep the CAT link up while transmitting (K3 shared-CAT FT8)
A K3 (and other Kenwood-dialect rigs) answers "?;" to an IF; status poll while
it is transmitting. ask() latched that as "IF unsupported" and ReadState
returned an error, so the Manager dropped the whole CAT link mid-transmit — the
log shows connected=false / "IF rejected" the instant JTDX sent TX;. That tore
down the shared rigctld CAT the digital-mode software keys through.

Two fixes: (1) track PTT (SetPTT) and skip the wire poll while it's held —
ReadState hands back the cached last state, capped at 30 s so a missed
SetPTT(false) can't freeze it; (2) never latch IF/ID as unsupported — those are
universal on Kenwood/Elecraft, so a "?;" to them is a transient busy (mid-TX),
not a missing command.
2026-08-06 17:49:46 +02:00
rouggy 4c1b4fd1a8 ui(awards): keep the Followed column sorted alphabetically like Available 2026-08-06 17:36:09 +02:00
rouggy f2cc4f11cd fix(awards): stop the follow-list flicker/lost clicks (inline rows, no nested component)
The Row helper was defined inside AwardsSelectionPanel, so each render minted a
new component type and React remounted the whole list on every click — flicker,
and clicks lost between mousedown and click (hence needing many tries). Inline
the row markup so the keyed buttons keep a stable identity.
2026-08-06 17:32:25 +02:00
rouggy faf084ddfc feat(awards): follow-list picker in Settings → Awards, filter the Awards tab
New per-profile "tracked awards" selection: Settings → Awards (under User
configuration) is a two-column transfer list — every defined award on the left,
the ones you follow on the right, click to move either way. The Awards tab's
list is narrowed to the followed set; an empty set means "show them all" so the
tab is never blank.

Backend: app_awards_tracked.go adds keyAwardsTracked (per-profile JSON array of
award codes) with GetTrackedAwards/SaveTrackedAwards; saving emits
awards:tracked-changed so the Awards tab re-filters live. Award definitions stay
global — only the follow selection is per profile.
2026-08-06 17:29:00 +02:00
rouggy 01dcd91253 fix(motor-antenna): don't inhibit TX on a band the antenna doesn't cover
The follow filter correctly leaves the antenna put on an un-ticked band, but the
TX-inhibit loop ran independently: it gagged FlexRadio transmit whenever the
antenna reported "moving" (a stray polled Moving flag, or a move finishing from
the previous band), even on a band OpsLog was deliberately not managing. An
operator working 15 m on another antenna had their FT8 cut mid-transmission by
an "antenna moving" interlock.

motorTXInhibitLoop now takes the covered-band set and forces moving=false when
the current rig band isn't in it — un-ticking a band means hands off entirely:
no tune AND no inhibit. Also correct the RCU-01 references to RCU-06 (the actual
controller behind the short 11-byte status frame).
2026-08-06 17:06:49 +02:00
rouggy 3d603d34aa debug(motor-antenna): trace the follow band-gate decision (why the antenna did/didn't move) 2026-08-06 16:33:42 +02:00
rouggy 3d1602c4bd docs(wiki): cover every ADIF import option (dup modes, cty/ClubLog, station fill, field remap) 2026-08-06 16:16:52 +02:00
rouggy a10fb2413a ui(antenna): drop the Covered bands help paragraph 2026-08-06 16:12:46 +02:00
rouggy bd8ca2e9de feat(motor-antenna): band selector for the follow filter (replaces min/max range)
A contiguous FreqMin/FreqMax range can't drop a single band while keeping its
neighbours — so it couldn't express "40 m and 20 m yes, 30 m no" for an antenna
whose 30 m extension isn't fitted. Replace it with a Covered bands selector
(40 m–6 m) that applies to both the Ultrabeam and the SteppIR.

The follow loop and immediate re-tune now gate on band membership
(motorBandAllowed) instead of a MHz range: only bands in the operator's set are
followed, and 80 m/160 m are never followed (outside a beam's reach) regardless.
The legacy FreqMin/FreqMax is migrated to a band set on load and still
round-trips, so existing configs are unchanged. UI swaps the two number inputs
for a row of toggle chips.
2026-08-06 16:07:42 +02:00
rouggy df56391eb8 fix(motor-antenna): honor the configured tunable range for the Ultrabeam
The follow logic skips out-of-range frequencies only when it knows BOTH edges
of the antenna's range. The Ultrabeam path took those edges from the
controller's status frame, but an RCU-01 behind an RS232-to-Ethernet bridge
answers with the short frame that omits FreqMax → the guard saw FreqMax==0 and
disabled itself → OpsLog forwarded every rig frequency, so 80 m (un-tunable)
reached the controller, which clamped the elements down to its lowest band
(~30 m).

ubAdapter now carries the operator-configured FreqMin/FreqMax from Settings →
Antenna (like steppirAdapter already did) and uses them as a hard floor/ceiling,
falling back to the controller-reported bound only when one is left unset. This
guarantees both bounds are present so the guard actually engages. The Tunable
range editor in the Antenna panel, previously SteppIR-only, is now shown for the
Ultrabeam too, with a generic bilingual hint.
2026-08-06 11:02:14 +02:00
rouggy 82ce3353c4 feat(rotator): add Hy-Gain DCU-1 backend (RotorCard DXA, Rotor-EZ, Green Heron)
New internal/rotator/dcu1 client speaking the DCU-1 command set (AP1nnn / AM1
to go-to, AI1 to read bearing), over a serial COM port (4800 baud default) or a
raw TCP serial-over-IP bridge. Azimuth-only; Stop re-commands the current
bearing since the base set has no stop opcode.

Wired through app.go (normRotorType, default port 4001, GoTo/Heading/Stop/test
switches, park returns the same "PstRotator only" note as ARCO) and exposed in
the rotor settings as "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)"
with the ARCO's serial/TCP transport chooser and a bilingual hint. Protocol is
implemented from the standard DCU-1 spec and still needs field confirmation
against a real RotorCard DXA.
2026-08-06 10:40:36 +02:00
rouggy 20dc3e83a6 ui(stats): prettier slot drill-down + click a callsign to edit the QSO
The pop-up listing the QSOs behind a Stats band/mode square gets a cleaner header
(band chip, hint), banded/hover rows and roomier spacing. Callsigns are now
clickable and open the QSO editor (App.openEdit threaded through DetailsPanel →
BandSlotGrid → SlotQSOModal as onEditQso), matching the double-click-to-edit of
the log grids.
2026-08-06 10:30:34 +02:00
rouggy 62cdabe114 feat(relay): accept a full URL host so relays work behind an HTTPS proxy
Network relay boards (WebSwitch/KMTronic/Dingtian) were always reached at
http://<host>, so a board on the LAN behind a reverse proxy — the common remote
setup where the operator's 80/443 already go to Nginx Proxy Manager — couldn't be
reached from outside. relayBase() now keeps a scheme the operator supplies
(https://relay.example.com, optional sub-path) and only defaults to http:// for a
bare host/host:port. UI hint + test added.
2026-08-06 09:26:16 +02:00
rouggy 9bfb44925b fix(cluster): refresh spot statuses by merge, not by clearing the cache
setSpotStatus({}) blanked EVERY spot's status, so logging one contact wiped all
the other NEW/NEW-BAND badges and they popped back a moment later. Instead,
re-fetch the shown spots and OVERWRITE each status in place (merge) — the just-
worked spot updates while the rest stay put, no flicker. Same visibility gate
and 2 s debounce; backend cost is one status batch (one logbook scan) as before.
2026-08-06 01:36:17 +02:00
rouggy fb1af9b6a2 feat(cluster): refresh spot statuses after a log — visibility-gated & debounced
A just-worked call/entity/POTA/prefix/slot kept its NEW badge because per-spot
status is cached and never expires. Drop the cache on qso:logged so the grid
re-evaluates. Kept cheap for old machines / big logbooks: only fires while the
cluster is on screen (a log while hidden marks it dirty and refreshes once on
next open), and debounced 2 s so a fast run coalesces instead of re-scanning the
logbook per QSO.
2026-08-06 01:13:22 +02:00
rouggy 57a9bed145 perf(cluster): dim spots via cell class, not redrawRows (0.23.6 slowdown)
The "dim spots that represent nothing" feature drove the dimming with getRowStyle
and called api.redrawRows() on EVERY spotStatus update to re-apply it — a full
grid re-render on a firehose of status updates, which pegged the CPU on a busy
cluster (reported as PCs slowing down since 0.23.6). Move the dimming to a
defaultColDef cellClassRules ('opacity-40') that the existing light
refreshCells({force:true}) re-applies; drop redrawRows and getRowStyle.
2026-08-06 00:55:49 +02:00
rouggy fbd323af26 fix(pgxl): fan mode needs the "setup" prefix — "setup fanmode=VALUE"
Confirmed live: "setup fanmode=CONTEST" is accepted (reply code 0) while the
bare "fanmode=CONTEST" we switched to earlier is rejected (0x50000015), so the
fan mode snapped back and never changed on the amp. Restored the "setup " verb
and now check the reply code, returning an error if the amp rejects the set.
2026-08-05 17:32:53 +02:00
rouggy 689d1cc902 feat(qso-edit): QRZ button to open the station's qrz.com profile
Mirrors the main entry form: a QRZ ↗ button by the callsign in the QSO edit
modal opens https://www.qrz.com/db/<call> via OpenExternalURL.
2026-08-05 17:13:34 +02:00
rouggy 67602fd485 docs(changelog): open empty 0.23.7 block 2026-08-05 16:43:32 +02:00
rouggy 2de5bce998 chore: release v0.23.6 2026-08-05 15:22:02 +02:00
rouggy f0959c796d fix(bandmap): call all hooks before the unknown-band early return (React #300)
The Ctrl+↑/↓ keyboard-nav useEffect sat AFTER `if (!range) return`, so on a band
with no range — e.g. a spurious 33 cm frequency briefly reported by an Icom over
CAT — the component returned before that hook and the hook count changed between
renders, crashing the whole window with React #300. Moved the effect above the
early return; lo/hi already fall back to [0,1] when there's no range.
2026-08-05 15:13:47 +02:00
rouggy 16e64fd7b7 fix(ultrabeam): accept an 11-byte status frame from older controllers
An older controller behind an RS232-to-Ethernet bridge returns the status frame
without its trailing FreqMax byte (11 bytes, not 12). The packet checksum is
still valid, so it's a real short frame, not a fragment. queryStatus now accepts
>= 10 bytes and defaults the missing FreqMin/FreqMax rather than rejecting it as
"too short", which reconnect-looped the link. Whether the byte LAYOUT is
otherwise identical across controller versions still needs confirming from the
protocol reference.
2026-08-05 14:49:52 +02:00
rouggy 7e696a72bb feat(cluster): dim spots that represent nothing
A spot whose entity/band/mode is already worked, whose callsign isn't in the log,
and that carries no POTA/county/prefix novelty is dimmed (whole row, opacity) so
the eye skips it. "Nothing" follows the status, which already obeys the "same
slot" option and digital grouping. getRowStyle drives it; the spot-status effect
now redrawRows so the dimming re-applies when a status lands.
2026-08-05 14:38:19 +02:00
rouggy 9af2732314 fix(cluster): WORKED chip filters worked-call only, not the entity status
The WORKED filter also matched the entity status 'worked' (entity/band/mode
already worked, shown as a plain "—"), so selecting WORKED still listed spots of
calls never worked. Match the 'worked' key only via worked_call (the blue
WKD-CALL flag); a worked call still shows under NEW BAND / NEW SLOT.
2026-08-05 14:26:34 +02:00
rouggy f82d92565f feat(cluster): add a WORKED status-filter chip
The filter logic already matched worked_call spots against a 'worked' key; add
the chip to the status row so worked spots can be shown or isolated, not only
dropped via the "Hide worked" checkbox.
2026-08-05 14:17:57 +02:00
rouggy d0f25aea9b feat(cluster): "already worked only on the same slot" option
New DX Cluster setting (Settings → DX Cluster). Off (today's behaviour) a call
worked anywhere reads as already worked; on, the WORKED-call flag needs the same
band AND mode. It folds through the digital-mode grouping (Settings → General):
grouped, a 20m FT8 contact also marks a 20m FT4 spot worked; ungrouped they are
separate slots. Backend: qso.WorkedCallSlotKeys builds CALL|band and
CALL|band|mode(grouped) keys; ClusterSpotStatuses uses them when the option is on
(a spot with no inferable mode falls back to same-band).
2026-08-05 14:11:06 +02:00
rouggy f91b83ee12 fix(awards): upper-case the DXCC main prefix (3D2/c → 3D2/C)
cty.dat spells some sub-entity suffixes in lower case; the awards prefix column
now shows them upper-cased like every other DXCC prefix.
2026-08-05 14:05:09 +02:00
rouggy a554257d5f feat(entry/stats/awards): Alt+W clear, slot drill-down, DXCC prefix column
Five operator-requested items:

- Alt+W clears the QSO entry. Handled before the `typing` guard and above
  the keyer's key routing, so there is always one key that clears whatever
  else is running — Esc is not that key when the CW keyer reserves it.

- The Grid box no longer pops outside the entry panel on a narrow window.
  Row 2 needed 300+130+76+gaps = 538 px inside a panel whose min-width is
  520, so Grid was pushed out and clipped at every narrow width, not just
  extreme ones. QTH's min-width drops to 80 and the row wraps rather than
  overflowing if it ever still can't fit.

- Selecting a QSO in the log now drives the Stats (F1) matrix. Uses its own
  WorkedBefore call into separate state, NOT runWorkedBefore: that one owns
  the entry form's wbRef and can trigger a field backfill, which browsing
  the log must never do. The entry form wins whenever it holds a call.

- Clicking a coloured band/mode square lists the contacts behind it. Returns
  the exact callsign AND the rest of the entity, because that pair is what
  the cell's colour encodes; the call's own QSOs are bolded. The DXCC arm
  matches the stored dxcc column only — reconstructing it from the callsign
  here would disagree with the matrix above, which is built from that column.

- The Awards DXCC list shows each entity's primary prefix in its own sortable
  column. Derived live from cty.dat into Ref.Group rather than stored on the
  reference row, so an existing installation needs no re-seed.
2026-08-05 13:47:36 +02:00
rouggy a20d9a0b76 fix(spe): drop the link on power-off so status shows off and ON works
Switching an SPE amp off left the already-open connection reading as connected
(the amp stops answering once off, but the stale open handle kept the status at
"connected"), so the panel never showed OFF and PowerOn — which only wakes a
DISCONNECTED amp, and must NOT pulse RTS/DTR on a running one — did nothing
until the operator restarted OpsLog. PowerOff now drops the connection and marks
the amp offline, exactly as a fresh start would; the poll's plain reopen never
wakes a switched-off amp, so it stays off until the ON button's RTS/DTR edge.
2026-08-05 11:02:44 +02:00
rouggy 92ee8aadde fix(entry): stop stray QSO logging from freq-Enter and bare-number DX call
Two ways a bogus contact could be logged:
- Enter in the TX/RX frequency field bubbled to the entry container's
  onKeyDown, which calls save() on Enter-in-an-input. Typing a frequency and
  pressing Enter tuned the rig AND logged the QSO. The freq fields now
  stopPropagation so Enter only tunes.
- WSJT-X/JTDX broadcasts its DX-call field over UDP; when it holds a bare
  number ("1"), applyUdpCall dropped it into the callsign, and the next Enter
  logged a QSO with callsign "1". Reject any DX call with no letter.
2026-08-05 10:45:58 +02:00
rouggy 61c6666a4c ui(cat): trim verbose Kenwood/CAT-sharing help text
Removed the long explanatory hints under the Kenwood COM/baud/host, data-mode,
CAT-trace and PTT-hotkey fields; shortened the CAT-sharing hint to just the
client setup line; and replaced the DTR/RTS hint with a one-liner. Less clutter
in the CAT settings.
2026-08-05 10:17:54 +02:00
rouggy 9f5c482667 fix(catshare): echo PTT state so JTDX doesn't drop transmit
JTDX polls get_ptt DURING transmit to confirm the rig is keyed (WSJT-X does not,
which is why it worked and JTDX cut after ~2 s). We answered a blanket "0" (RX),
so JTDX concluded PTT had failed and dropped the over. get_ptt now echoes the
last PTT state commanded via set_ptt — always consistent with the client's own
command. Trace showed T 1 → three t→"0" polls → T 0.
2026-08-05 09:55:27 +02:00
rouggy b9ea077a64 fix(catshare): revert FT8 freq echo — it blocked JTDX transmit
The optimistic get_freq echo added in 0.23.5 (to stop the "Fake It" dial creep)
stopped JTDX/WSJT-X from going into transmit: the client reads frequency back
during its TX sequence, and echoing the commanded value instead of the rig's
live value interfered with it. No-TX is far worse than a 0.5 kHz drift, so revert
to reporting the rig's live frequency. get_freq/get_split_freq return s.rig.Freq()
again; the echo state, noteSetFreq, reportedFreq and the drift test are removed.
2026-08-05 09:26:33 +02:00
rouggy a48fa0b2e7 chore: add another callsign to the startup gate 2026-08-05 09:15:55 +02:00
rouggy 821883dfd3 fix(ultrabeam): flush stale replies (not seq-match) + instant moving flag
The antenna does NOT echo our sequence number — its replies carry their own
counter — so the previous seq-matching drained every reply and stalled status
updates. Revert to reading one reply per command, but flush any bytes left in
the stream before each command (drainStale): a reply left by a timed-out command
is discarded so the next read stays 1:1. readPacket also resyncs to the next STX.
This fixes the intermittent disconnects, phantom frequency jumps and wrong
element-length readings without depending on the seq.

Also: report motion for a short window right after a commanded move, so the
"moving" indicator and the Flex TX-inhibit fire the instant a band/pattern is
clicked instead of a poll (~2 s) later; the real motor state takes over once
polled.
2026-08-05 00:13:53 +02:00
rouggy ef087492cc perf(logviewer): 512 KB tail instead of 1 MB
1 MB (~6500 lines) fetched, split and re-rendered every second made the window
lag. 512 KB (~3200 lines) keeps a useful backlog — double the original 256 KB —
while staying responsive.
2026-08-04 23:58:38 +02:00
rouggy 98f11ee3d0 fix(ultrabeam): match replies to requests by seq (stop stream desync)
sendCommand discarded the reply's sequence number and accepted whatever frame
came back. On a slow remote link a command that timed out left its late reply in
the stream, and the NEXT command read it as its own — crossing STATUS with
READ_BANDS/PROGRESS. That surfaced as phantom frequency jumps (a spurious
follow-loop re-tune), intermittent "reply too short" disconnects, and wrong
element-length readings (READ_BANDS getting the status frame).

Now every reply is matched to its request seq and stale/malformed frames are
drained (bounded), with readPacket resyncing to the next STX. Tested with a
net.Pipe that injects a stale reply ahead of the real one.
2026-08-04 23:56:42 +02:00
rouggy 058f164ab6 docs(changelog): move log-viewer entry to 0.23.6 (0.23.5 already released) 2026-08-04 23:40:59 +02:00
rouggy 6c47c27775 feat(logviewer): keep ~4x more history (1 MB tail)
The viewer fetched only the last 256 KB (~1600 lines) of opslog.log. During a
busy trace the oldest lines scrolled out of the window while the operator was
still reading them. Raise the tail to 1 MB (~6500 lines); the backend already
allows up to 4 MB.
2026-08-04 23:37:55 +02:00
rouggy 7410fa4825 chore: release v0.23.5 2026-08-04 23:23:40 +02:00