Commit Graph
776 Commits
Author SHA1 Message Date
rouggy 9c33feecfa feat(ui): PSK Reporter chip in the status bar
Beside the rig and amplifier chips, before ON AIR, because it is the same kind
of fact they report: a link that is either up or it is not. Clicking it opens
the settings that control it, like the amplifier chips do.

Shown ONLY while the opening watch is on. A permanently grey chip for a feature
nobody enabled is clutter, and the bar is 28 px tall — every chip in it has to
earn its width.

Green once decodes are arriving, amber while connected but silent: those are
different states and an operator wondering why no opening has been announced
needs to tell them apart. The count itself is in the tooltip, not on the chip —
it moves several times a second on an open band, and a number flickering in the
corner of the eye is a distraction rather than information.
2026-08-11 13:08:04 +02:00
rouggy 8afba2c4e8 feat(bandopen): read PSK Reporter, and arrange the sources the watch needs
The detection shipped reading whatever the operator's cluster nodes happened to
carry. On VHF that is a few hundred skimmers, nearly all of them on HF: a 6 m
opening carrying 869 stations reached OpsLog as a handful of spots or none, and
Nexus flagged it on the same PC while OpsLog stayed silent.

internal/pskr subscribes to pskr/filter/v2/<band>/# on PSK Reporter's MQTT
broker. Every ordinary station running WSJT-X reports what it decodes, so the
difference is two orders of magnitude rather than a threshold. The feed's shape
suits us exactly: BOTH grids are in each message, so distance and bearing are
arithmetic — no lookup, and no DXCC-centre approximation, which is what made the
cluster path's bearings coarse. The geometry is injected from app.go so it stays
the same arithmetic the cluster path uses; two answers to one question is how a
bearing quietly becomes wrong.

Volume was the design constraint, not the protocol. Six metres open is thousands
of messages a minute and this runs on some very old PCs, so nothing is kept or
persisted in the watcher: each message is parsed, measured and handed on or
dropped, and the detector's existing window does the deciding.

And the part that was the real bug: enabling the watch now ARRANGES ITS OWN
SOURCES. It adds the two RBN nodes when missing and brings the feed up. A
feature that silently depends on sources nobody can know are needed does not
look unconfigured, it looks broken. Matched on host and port, not name, so an
operator who renamed theirs does not get a duplicate — which the detector would
read as twice as many stations, and announce an opening that is not there.

Turning it off leaves the nodes alone: they may have been wanted for their own
sake, and removing a node someone is using is worse than leaving one they are not.
2026-08-11 12:48:29 +02:00
rouggy 2d3bfa704e fix(bandopen): drop the distance ceiling
The detector refused any path over 2400 km, reasoning that past a single hop the
bearing test stops meaning anything. That was wrong, and it discarded exactly the
openings worth announcing: Nexus flagged a 6 m opening at 5477 km that OpsLog
never saw, because the spots were thrown away before any test ran.

Multi-hop Es is ordinary on 6 m — 5000 km paths are common, 10000 km happens —
and it stays directional: a second hop leaves the sector the first one entered.
So the sector test, which is what does the real work here, holds perfectly well
at any distance. The ceiling was standing in for a judgement it could not make.

The floor stays at 500 km: a short 6 m contact is tropo or ground wave and says
nothing about the ionosphere. Both are now pinned by a test.
2026-08-11 12:30:51 +02:00
rouggy 29bdea1e43 feat(rigctld): answer lock_mode and stop_morse instead of refusing them
Both are ordinary Hamlib commands, and Nexus sends them around every transmit.
Refusing with RPRT -11 is permitted and a tolerant client carries on, but
nothing obliges it to — and while they sit in the log as "unimplemented" they
stay suspects every time something else goes wrong.

get_lock_mode answers 0, which is true: OpsLog never locks the dial against its
own clients. set_lock_mode is accepted and ignored, like set_vfo — there is no
lock to set, and failing would abort a client's transmit sequence over a setting
with no effect either way. stop_morse answers success because nothing is queued
here: CW is keyed by the rig's own keyer through the backend, never buffered in
this server, so "stopped" is accurate rather than polite.

Whether this is what Nexus is actually unhappy about is not established. It
removes two known irritants and two lines of noise from the log; if the trouble
persists, what remains in the log will be about the trouble.
2026-08-11 12:14:12 +02:00
rouggy b93c67f3cb fix(webpub): the column search box must not be scrolled over
It was sticky inside the scrolling list, and the rows went over it: a menu item
carries its own background and its own stacking context, so it wins against a
sticky sibling however high the z-index is raised.

The menu is a flex column now — a header that never scrolls, and the list
scrolling beneath it. Nothing to lose the fight with.
2026-08-11 11:25:52 +02:00
rouggy 735dfe69db fix(webpub): the column picker is a dropdown, not a wall
The first attempt laid 123 fields out on the page, sectioned by group. That
buried every other setting in the panel and was no easier to read than the flat
wrap it replaced — more surface, same problem.

One dropdown now, alphabetical, with a search box pinned at its top. Alphabetical
because any other order means hunting: the operator arrives knowing the name of
the field they want. The menu stays open while ticking, since picking eight
columns should be one visit rather than eight.

The chosen columns keep their place above it, in publication order — that list
answers "what will the page look like", which no catalogue can.
2026-08-11 11:11:24 +02:00
rouggy 67fe5bcff0 feat(webpub): offer every field, and make choosing among them possible
The catalogue held 23 hand-picked columns, so publishing a county, a satellite
pass or an award reference was simply not possible. It is generated from the
qso.QSO struct now — 123 fields — which also means a new ADIF field cannot be
forgotten here, as a hand-written list always eventually is.

Three keys were renamed to their ADIF names on the way (pota, sota, station).
An alias map keeps existing configurations publishing the same columns; without
it three would have vanished silently on upgrade, which is the worst way for a
setting to change.

The picker had to change with it: 123 chips in one wrap is a wall nobody reads
to the end of. It is sectioned by group, filtered as you type, and the chosen
columns sit on top in publication order — after picking eight out of a hundred,
the question stops being "what exists" and becomes "what did I pick".

The package doc said columns were curated so the operator's address could not
be published. That is no longer true and the comment now says so plainly: the
judgement moved to the operator, the default selection is unchanged, and nothing
is published that was not chosen.

Also collapses the changelog: four separate shared-CAT entries were one thing
from where the operator sits, and all of them were far longer than the one or
two sentences this project asks for.
2026-08-11 10:59:01 +02:00
rouggy d36d431d9a feat(rigctld): only pass PTT on when it actually changes
Nexus sends set_ptt 0 about sixteen times a second, and every one of them became
an "xmit 0" to the FlexRadio — a write every 60 ms, for ever, saying nothing.

Repeating a state is not a request to change it. Only transitions reach the
radio now, and each is logged, so the next "it will not transmit" can be traced
to whoever asked rather than inferred from a wall of identical lines. The first
call always goes through: how the radio was left is not ours to assume.

This is not the whole of the reported symptom, and should not be read as such.
The log also shows "xmit 1" followed by "xmit 0" one to two milliseconds later,
off the 60 ms cadence — that is a genuine transition pair, so it still gets
through and the radio still unkeys at once. Those two commands come from the
client, not from here; the writes originate only in set_ptt, and there is no
loop in OpsLog that emits them.
2026-08-11 10:29:23 +02:00
rouggy 0c8d79e2fa feat(rigctld): say so when another program owns the CAT port
Listen() succeeding is not the same as being reachable. OpsLog binds 0.0.0.0,
and Windows lets a second program bind the SAME port on the specific address
127.0.0.1. Connections to localhost then go to the more specific listener, so
every client reaches the other program while ours sits there having logged
"sharing CAT on port 4532" and never seeing a single connection.

Found with Nexus, which starts its own rigctld on 127.0.0.1:4532 and talks to
it. Neither side reports anything wrong: the operator gets a CAT timeout from a
daemon with no radio behind it, and OpsLog's log is silent because nothing ever
arrived. Three exchanges went into establishing that the connection simply never
reached us — the port table was what settled it, not the code.

So the server now dials its own port at startup and checks the connection lands
on its own accept loop. If it does not, it says which program will be receiving
the CAT connections and what to do. The counter it compares can only be raised
by our own accept loop, so a real client arriving during the probe makes the
check pass, never fail wrongly.
2026-08-11 10:15:19 +02:00
rouggy 796a1d8e7d feat(rigctld): name the misconfiguration instead of timing out silently
A client set to a RIG MODEL (Kenwood, Yaesu, …) pointed at the CAT-sharing port
speaks raw rig dialect: "ID;", "IF;". That is not rigctl, so it fell to the
unknown-command branch and got RPRT -11 like anything else.

The symptom hides the cause completely. RPRT -11 has no ';' for the client's
parser to terminate on, so it waits out its timeout and reports "reply
incomplete, got nothing" — a hard failure that reads as "OpsLog's CAT sharing
does not work", when it is one setting in the other program.

A frame ending in ';' with no space in it cannot be a rigctl command, so the log
now says what it is and what to set instead. Reported from Nexus, whose Hamlib
error named kenwood_transaction — the one word that gave it away.
2026-08-11 09:54:28 +02:00
rouggy 9531a54ac1 docs: changelog the shared-CAT fix that was committed without one
cfdd24d shipped the "?;" tolerance with no changelog entry, against the
project's own rule. Placed before the CW entry: the link had to survive being
busy before anything keyed through it could matter.
2026-08-11 08:59:03 +02:00
rouggy d2d64706f5 fix(kenwood): KY takes a FIXED 24 characters, not a string
CW over CAT never worked on a Kenwood. The KY implementation was written against
Elecraft's, which accepts a string of any length, so "KY OH5CX;" went out and a
TS-590SG answered "?;". OpsLog read that as "this radio refuses CW over CAT" and
told the operator to fit a serial keyer — advice that was wrong, and expensive.

The TS-590 manual is explicit: P2 has a fixed length of 24, blanks are filled
with spaces, and those spaces are not keyed. So the fix costs nothing on air; it
is simply the shape the command has. Elecraft stays variable-length, where
padding would key the trailing spaces as word gaps.

The semicolon is also gone from the allowed CW characters. It TERMINATES a CAT
frame, the manual forbids it in P2, and one in a macro would have closed the
command early and left the rest of the message to be read as commands.

Found from a log and a manual page, not from a rig: nobody here owns a Kenwood.
What is proven is the frame shape; that a TS-590SG then keys it still needs the
operator to confirm.
2026-08-11 08:55:49 +02:00
rouggy cfdd24d52e fix(kenwood): a busy rig is not a lost rig
A Kenwood answers "?;" while it is busy, and the TS-590SG does it for a moment
after RX; — which is exactly when the poll resumes. One rejected IF; then tore
down the whole CAT link.

The code already knew this. Its own comment says a "?;" to IF; is "a transient
busy, NOT unsupported" and that latching it off "would read as lost the rig".
That reasoning was applied to the unsupported-command map and stopped there: the
call still returned an error, the poll loop still turned it into connected=false,
and the link still dropped.

What it cost was not cosmetic. WSJT-X keys through shared CAT, so it lost the
rig mid-sequence; Hamlib then sent "F 9223372036854775808.000000" - an
uninitialised 2^63 - which OpsLog refused and logged. The operator sees a
frightening frequency error whose actual cause is three lines earlier.

A "?;" is now sentinel-wrapped so the poll can tell "the rig declined" from "the
serial link is gone", and three consecutive ones are ridden out on the last
known state before the link is called dead. Under a second of tolerance: enough
for the rig to finish, far too short to hide an unplugged cable. A serial fault
is untouched and still drops immediately.

Tested against the emulated rig already in this package, with a hook that
answers "?;" on demand: the link survives inside the grace and still reports the
last good frequency, the count resets on recovery so a later busy spell gets the
full allowance, and a rig refusing forever is still reported as broken.
2026-08-11 08:37:24 +02:00
rouggy 8052bd2935 update 2026-08-10 23:40:42 +02:00
rouggy 55879809f2 fix(udp): ignore N0CALL, and stop returning a grid as a callsign
N0CALL is what WSJT-X transmits under when its owner never set a callsign. It
has a letter, a digit and an ordinary shape, so nothing rejected it: it was
spotted, coloured, counted as a new WPX prefix, and now that CQ grids are read
it would have put a grid into the worked index under a callsign nobody holds.

The obvious fix - adding it to looksLikeCall's reject list - was wrong, and the
test caught it before it shipped. That function answers "could this token be a
callsign at all", and the CQ grammar uses it to decide whether the word after CQ
is a modifier (DX, NA, a zone) or the call itself. Teaching it that N0CALL is
not a callsign made "CQ N0CALL JN36" skip a slot and return JN36. Shape and
policy are different questions and now live in different functions.

Chasing that turned up the real defect behind it: ANY unrecognised word after CQ
made the parser skip a slot, and a four-character grid passes every shape test a
callsign does. "CQ FOO JN36" returned JN36 as the sender - logged, spotted and
coloured as a station. A grid in the callsign slot is now refused outright.
2026-08-10 21:51:49 +02:00
rouggy 6969560efb chore: open 0.24.5
Empty block at the top so the next change has somewhere to go. 0.24.4 keeps its
seven entries; the release script stamps the version constants.
2026-08-10 21:19:31 +02:00
rouggy 7eff19f6e2 chore: release v0.24.4 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
rouggy 6497568813 chore: release v0.24.3 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 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 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