Commit Graph
207 Commits
Author SHA1 Message Date
rouggy df1d2767cd feat(antgenius): the selected antenna can fill MY_ANTENNA
A station with a switch knows something the log does not: which antenna
is actually connected. The working conditions hold what was PLANNED for
the band, and stay right until the operator throws the switch — after
which every QSO keeps claiming the other antenna.

Optional, and off by default: a station that names its antennas
differently in the two places would otherwise find its log quietly
rewritten. The name written is the one configured on the device, since
that is the name the operator gave it and the one they will look for.

WHICH PORT is the real problem, and the reason this is not a one-liner.
The switch has two, the radio has two jacks, and the QSO went out through
one of them; naming the wrong port's antenna is worse than naming the
band default, because it looks authoritative. The radio's own TX antenna
selection decides, and the jack-to-port wiring is a setting — it is the
station's cabling and neither device can report it.

When the port cannot be told — a transverter jack, a rig that reports
nothing, both switch ports live — nothing is written and the band default
stands. Silence beats a confident guess in a field nobody re-checks.
2026-08-25 15:08:48 +02:00
rouggy b38b9030f0 fix(elecraft): MOX would not unkey, and the TX meters never moved
Both are the same bug. ReadState returns early while transmitting — the
rig answers '?;' to IF; then, and treating that as a fault used to drop
the link — but that early return also skipped the panel update and the
meter read.

So the panel believed the radio was still receiving. Pressing MOX again
therefore sent ANOTHER transmit command instead of RX, and the K3 stayed
keyed, exactly as reported. And the power and SWR bars were only ever
read at the one moment they mean nothing.

Reading meters while transmitting needs one more guard: a '?;' then says
when the question was asked, not what the radio supports, so the
unsupported-command memory is suspended for that read. Without it, one
badly timed refusal would silence a meter for the rest of the session.

Adds RIT/XIT offset control (±10/±100 Hz, offset shown), a 4.0 kHz filter
button for FT8, and logs the K3's icon word when it changes: there is no
command for 'is the ATU in line', the reference says the switch functions
show up as icon changes, so an operator toggling the ATU while watching
the log will name the bit and the button can then light up honestly.
2026-08-24 21:06:21 +02:00
rouggy 0848c275d2 fix(yaesu): mic gain scale, 200 W models, and a NAR button that did nothing
Reported from a real FTDX101, and each one is a different kind of wrong.

MIC GAIN was read and written through the 0-255 scale the audio gains
use, but the CAT reference gives MG000-100. A rig set to 80 therefore
showed 38, and moving the slider sent 204 — outside the range the radio
accepts, so it refused the command and the slider sprang back. That
snap-back was the symptom; the scale was the cause.

POWER was capped at 100 W by the slider, not by the radio. There is no
CAT command for 'how much power can you make', so the ceiling comes from
the model name, with the rig believed if it ever reports more than the
table expects — it has just proved what it can do.

NAR is the narrow IF filter, and on a rig that does not implement NA the
button showed a state the radio never gave and did nothing when pressed,
which reads as a fault in the radio. Now shown only when the rig answers,
with a tooltip saying what it is.

Also: the frequency readout steps the Hz digits under the wheel, not just
the kHz ones. Zero-beating a CW signal is a few tens of Hz and it was the
one move the display would not make. And the split chaser is CW-only —
its marker comes from a CW skimmer.
2026-08-24 17:59:13 +02:00
rouggy dd77e7f467 feat(elecraft): the rest of the main controls on the K3/K4 console
So the tester can exercise everything in one session instead of one
control per build: RF and mic gain, squelch, preamp, attenuator, NB, NR,
AGC, filter width, antenna, RIT/XIT with a clear, and the keyer speed.

The analogue scales differ between an Elecraft and a Kenwood — RF gain
000-250, mic 000-060, squelch 000-029 against 0-255 — so each is mapped
through its own full scale and shown as a percentage. Using one rig's
range on the other silently halves or doubles every setting.

Antenna only appears when the radio answers AN: a K3 without the internal
ATU has one socket, and a switch that goes nowhere is worse than none.
2026-08-24 14:29:38 +02:00
rouggy 60dad353ce feat(elecraft): a K3/K4 console — power, volume, S-meter, SWR, MOX, tune
The Elecraft backend already existed; what was missing was somewhere to
operate the radio from. This adds the panel, on the Kenwood-dialect
client the K3 already speaks, in a tab of its own beside the Yaesu and
Icom consoles.

Scope is the six controls asked for and nothing else. Every extra command
added without a radio to test it against is a control that may or may not
do what its label says, and a K3 exposes dozens.

No K3 was available while writing this, so the two halves are treated
differently. The setters are the commands the reference documents
unambiguously and whose effect is visible and reversible (PC, AG,
TX/RX). The meters are the opposite: their scaling differs by model and
firmware, so the raw answers are logged next to the power setting, the
panel says the scaling is provisional, and an unmeasured SWR shows as
'—' rather than as a perfect 1.0 — a good match on an antenna nobody
measured is the reading that costs a radio.

ATU tune sends SWT20 (the K3 front-panel tap) and logs exactly what it
sent, so a wrong mapping names itself instead of leaving an operator
guessing which button OpsLog pressed.
2026-08-24 14:09:28 +02:00
rouggy 2dd68da284 feat(flex): chase a split pile-up on the skimmer's report marker
Working a DXpedition split means guessing where it listens. The useful
information is not the callsign the DX answered but the FREQUENCY that
station was calling on, and a CW skimmer already marks it: SDC posts each
decoded report to the panadapter as a spot.

Those spots reach OpsLog through the radio's spot feed. On a marker, the
TRANSMIT slice moves there plus a signed offset; the receive slice never
moves, because losing the DX is worse than missing a call. The marker
text is a setting -- it is chosen in SDC by the operator, so any constant
here would be wrong for whoever chose otherwise -- and the switch is a
button beside SPLIT, since it is turned on when a DXpedition appears and
off when it is worked.

Moves are throttled and ignore a marker landing where the slice already
is: a busy pile-up produces several reports a second and the slice would
otherwise never be anywhere long enough to call.

The spot feed is now subscribed to unconditionally. It was tied to
OpsLog's own spot overlay, so an operator running SDC with the overlay
off received nothing -- the reason no foreign spot was ever logged. The
connect-time 'spot clear' stays behind the overlay flag: it wipes every
spot on the radio, a skimmer's included.
2026-08-23 18:49:40 +02:00
rouggy 803f4dc4ed feat(hamlog): read confirmations back from a HAMLOG.online ADIF
Their agent protocol has no download verb, so the return path is a file
exported from their site. Running that file through the ordinary ADIF
import is the wrong tool and does real damage: it matches on callsign +
UTC minute + band + mode, their export is rebuilt from their own
database and rarely agrees to the minute, and 'update duplicates' then
inserts everything that failed to match -- several hundred copies of
contacts already in the log.

This path matches only. It stamps the confirmation on QSOs it finds,
falls back to the mode-CLASS key for the modes their export renames, and
REPORTS what it could not match instead of adding it: an unmatched
confirmation is a question about the log, not a contact to create.

Also logs and reports how many rows a delete actually removed -- silence
there made a delete that did nothing indistinguishable from one that
worked.
2026-08-23 15:32:41 +02:00
rouggy b3e398b8d6 feat(rda): measure the two district sources before arbitrating between them
A log imported from HAMLOG.online carries the Russian district in CNTY, and the
offline RDA database has its own answer for the same contact. Which wins is a
real question — but only if the two are independent. If HAMLOG's value is itself
a lookup in the same kind of database, there is nothing to arbitrate and a
precedence rule would be ceremony around a redundancy.

So this measures instead of deciding: Settings → RDA gains a read-only compare
that reports agreements, disagreements, and the contacts where only one side
knows, then lists the disagreements — callsign, date, both districts, whether
the database answer was a DATED record (the award administrators' own fact) or
the callsign's current district (an assumption), and whether the QSO is
confirmed on HAMLOG (their value has then been through a match of two logs).

It changes nothing in the log. What to do about a conflict is the next decision,
and it should be taken with the operator's own numbers in hand.

Also: the four HAMLOG fields become filterable, so 'never sent there' is a
filter rather than a guess. A field that can be written and not read back is
half a feature — pinned by a test against the bulk-editable list.
2026-08-23 14:30:48 +02:00
rouggy 086581851b feat(hamlog): confirmation defaults and grid columns; drop a settings paragraph
Preferences → Confirmations gains a HAMLOG.online row, sent and received, with
the same defaults every other service has: a new QSO can start at N so the
backlog is meaningful from the first contact. The defaults reach it through a
new setExtraDefault — fill() writes through a pointer to a string field and
these two live in the extras map, which no pointer reaches.

The QSO grid gains four HAMLOG columns (status and date, both directions),
hidden by default: a column per service shown to everyone is how a grid becomes
unreadable.

And the paragraph explaining what "Sent" means is gone from that page.
2026-08-23 13:25:20 +02:00
rouggy ee148732dd feat: HAMLOG.online upload and confirmations, Yaesu antenna, a named port holder
HAMLOG.online is a seventh external service: one API key, one ADIF record per
QSO, immediate / delayed / on-close like the rest. They publish no API
documentation, so the protocol is read from THEIR OWN client — the HAMLOG Agent
(github.com/hamlogonline/Agent), which is the authoritative source short of
asking them:

	POST https://hamlog.online/api/agent/
	{"ADIFADD": {"APIKEY": k, "ADIFDATA": record}}   → {"STATUS":"OK"}
	{"KEYSTATUS": {"APIKEY": k}}                     → {"STATUS":"OK","CALLSIGN":…}

Success is STATUS == OK, not "no ERROR field": their failure carries ERROR and
no STATUS, and reading an unknown reply — a proxy page, a maintenance notice —
as an acceptance is how a contact goes missing without anyone noticing.

KEYSTATUS buys something no other service here offers: the key can be checked
BEFORE the first QSO, and the answer names the account. A key pasted from
another callsign is caught in the settings panel rather than through a week of
silent refusals.

Their confirmations are also an award source now, ticked like LoTW rather than
expressed through "custom". It reads the ADIF extras, not a column: the standard
names a field for hamlog.EU and none for hamlog.ONLINE, and borrowing the other
site's field would write a falsehood into every exported log. Three plausible
key names from their own export are accepted too, so nobody has to rename a
column by hand after an export.

Yaesu gains antenna selection (AN), remembered per band — the antenna picked on
a band comes back with it, with no table to fill in anywhere. Rigs with one
socket never answer AN and never show the row; the log says which case it is.

And a serial port that is refused now names its likely holder. OmniRig stays
resident once activated and keeps the port of the rig configured in it, so a
native backend never gets it — "Serial port busy" alone accused nobody, and an
FTDX10 spent a morning being blamed for it.
2026-08-23 12:18:27 +02:00
rouggy f50806fbd9 feat: panadapter spots that read, a CI-V link that survives, no auto-call
Panadapter spots are now worth reading. The comment carries the spotter, the
entity and the status in DXHunter's own shape — "CQ up 2 [F4BPO] [Franz Josef
Land] [New Slot]" — which needed two things nothing documents: SmartSDR splits
its command line on SPACES, so the words ran together until every space became
non-breaking; and it truncates past ~60 characters, so the cluster's own words
are trimmed first and the three brackets always survive. RBN column padding is
collapsed on the way in, or a preserved run of spaces opened a gap wide enough
to push the rest off screen.

"Already worked" means the CALLSIGN is in the log, not the entity: saying it of
a station never contacted was simply wrong. Each status can also be kept off the
panadapter entirely, and the WSJT-X decode spots obey the same switches — the
palette governs the panadapter, not one of the two things that feed it.

And the radio is no longer hammered: a spot whose frequency, colour and comment
are unchanged is not removed and redrawn. A busy skimmer feed re-spots the same
station every few seconds; one two-minute session sent 2128 adds, 88 of them for
a single callsign, and the display did not move a pixel for any of them.

CI-V, from an IC-7850 that kept killing JTDX: a reply the rig sent to another
controller on the same bus is no longer taken for ours, and a set_ptt, set_freq
or set_mode whose acknowledgement goes missing is verified by reading the rig
back instead of being reported as a failure. WSJT-X and JTDX answer a failed
command with a Rig Control Error and drop the link mid-over — 98 keyings, 6 lost
acknowledgements, 2 dropped connections in one session. The check waits 700 ms,
not the poll's 150: the rig has just failed to answer twice because it was
retuning, and a short probe would fail for the same reason.

Auto-call is withdrawn — it duplicated DXHunter, which already answers decodes,
and two programs deciding that from one shack key over each other. The library
is kept whole and dormant; a guard in App.tsx makes sure a stored preference
cannot key a transmitter whose switch no longer exists.

Also:
  - the log rotates while running, not only at startup: the CI-V trace left on
    wrote 416 MB and nothing would have stopped it before the disk did. Closing
    it now releases the crash file too — the runtime keeps its own duplicate.
  - the interface zoom announces itself, with a badge, a click back to 100% and
    a View menu; Ctrl+wheel and Ctrl+0 always worked and nothing said so.
  - no more elastic bounce, and no swipe-to-navigate out of the app.
  - Edit QSO: your own TX power and the contacted station's extended locator
    were saved and written back with no box to set them.
  - FT decodes: continents are a multiple choice; a compound MSHV message that
    answers two stations in one line is recognised as addressed to you.
2026-08-23 01:16:26 +02:00
rouggy abfed4afa2 chore: release v0.26.4 2026-08-22 08:35:49 +02:00
rouggy d48420485f chore: release v0.26.3 2026-08-21 23:28:55 +02:00
rouggy 0cd243fe00 chore: release v0.26.2 2026-08-21 18:21:33 +02:00
rouggy e6889caae9 chore: release v0.26.1 2026-08-21 01:43:14 +02:00
rouggy 1e507225dd chore: release v0.26.0 2026-08-20 17:53:55 +02:00
rouggy 9599c3e0b9 chore: release v0.25.9 2026-08-18 05:09:04 +02:00
rouggy 5e80c27f61 feat(appearance): the band/mode matrix colours can be chosen
The PH/CW/DIG grid in the Stats panel is the fastest read in the app and its
palette was fixed per theme. Settings -> Appearance now offers the six: the
four status fills, the never-worked fill, and the ring on the cell being
entered.

Stored as OVERRIDES, not as a palette. Each of the twelve themes ships an
--mx-* ramp tuned to its own background, so an operator who only wants a
different green must not thereby freeze the other four to the theme they
happened to be using that day. An empty value means "whatever the theme
says"; the chosen ones are stamped inline on <html>, where they win over
every theme; switching the feature off hands the colours straight back.

The pickers are seeded from what the matrix is painting at that moment
rather than from a fixed palette, so the choice starts from the colours in
front of the operator. A new --mx-cur token carries the current-entry ring:
it follows --warning by default, so it stays theme-correct on all twelve,
but can be recoloured without dragging every other warning in the app along.

The legend under the matrix and its cell tooltips were hardcoded English.
They now go through t() with the same keys as the pickers, so the grid and
the settings cannot disagree about which green is which.
2026-08-17 16:23:39 +02:00
rouggy 0bab7f05b9 feat(relays): accept a self-signed certificate on a generic HTTP board
HTTPS to a relay board could not work. Nearly every board that offers it signs
its own certificate — there is no authority anywhere that could have signed it —
so the request failed verification before it left.

A checkbox, per board, off by default. Not a blanket switch, because the other
HTTPS case is real and opposite: a board reached from outside through a proxy
with a genuine certificate, where verification is the only thing standing
between an antenna switch and the internet. Same setting, two boards, different
answers.

Off by default is only safe if the failure explains itself, so a certificate
error now names the box to tick. Go's own "x509: certificate signed by unknown
authority" is accurate and tells an operator nothing about what to do next.

Shown only once an https:// URL is actually in the board's configuration. A
board on plain HTTP has no certificate to argue about, and an option that cannot
matter yet is one more thing to wonder about.

The flag joins the driver cache key: ticking it has to rebuild the driver, or
the cached one would go on refusing the certificate with the verifying client it
already holds.

The boards that take a bare host — WebSwitch, KMTronic — keep verification. An
https:// typed there is the proxy case by construction, since they default to
plain HTTP on the LAN.

Three tests against a real self-signed TLS server: accepted with the box,
refused with a message naming it without the box, and one board's setting not
leaking into another's.
2026-08-17 10:57:29 +02:00
rouggy 72ec3cbb97 feat(cat): share the rig over TCI as well as Hamlib — one or the other
internal/rigctld exists because Windows gives a COM port to ONE process: the
moment OpsLog talks to the radio natively, nothing else can. It answers the
programs that speak Hamlib NET rigctl. This answers the ones built around Expert
Electronics' TCI instead — and it answers them whatever radio is connected,
because it sits on the same backend-agnostic Rig interface. An operator with an
Icom or a Yaesu can now hand a TCI-only program a working rig.

One server or the other, never both. They answer the same questions about the
same radio, nothing speaks both, and a second listener is only a second thing to
go wrong.

Written against the official TCI Protocol document (ExpertSDR3/TCI, 12 January
2024, MIT — downloaded and read, not recalled): the initialisation set of §4.1
in its documented order, and the argument order of every command from §4.2. A
client will not proceed past connect without that block, which is why it is
written out in full rather than stubbed.

The one dangerous detail is the VFO mapping. TCI's channel A is where you
LISTEN and channel B where you transmit — the opposite way round from OpsLog's
RigState, which follows ADIF. Getting that backwards would put a station on the
DX's own frequency, so it is pinned in both directions by a test, and RxFreq was
added to the adapter rather than inferred.

Writing channel B while the rig is simplex is ignored: the client asked to
prepare a split transmit frequency, not to QSY, and a logger doing that on every
spot click would drag the operator off the station they were listening to. A
backend that cannot split still refuses out loud.

Only changes are pushed. TCI clients redraw on each command, so re-sending an
unchanged frequency four times a second makes a VFO readout flicker and fights
the operator's own tuning.

Nine tests, no socket needed — the protocol is the decision, not the transport.
2026-08-17 02:35:21 +02:00
rouggy 91b21a4a36 feat(sync): the folder loop, the hooks and the panel — it can be switched on now
The three layers underneath were already there and unreachable: the change-log
format (7d664bd), the identity column (724e68b) and the no-backfill decision
(c48294b). This is the wiring that gives the operator a switch.

Settings, all profile-scoped, because each profile can point at its own logbook:
the folder, this PC's name, the machine id minted once from it, the per-peer read
offsets and the tie-break counter. Scoping the machine id is what keeps two
profiles sharing one folder from writing two logbooks into one file.

Three hooks. Add and update publish asynchronously, down with the rest of the
after-the-fact work — a folder on a network share can block for seconds and a
contact belongs on screen long before another machine hears about it. Deletion
publishes SYNCHRONOUSLY and BEFORE the row goes, for the same reason
deleteRemoteCopies does: once it is gone its identity is gone with it and the
tombstone names nothing.

The apply path uses the repository directly and never AddQSO/UpdateQSO/DeleteQSO
— those publish, and a change applied here would be written straight back out,
two machines echoing each other for ever.

Saving writes a probe file to the chosen folder rather than asking whether it
exists. A read-only cloud folder, or a share whose credentials expired, exists
perfectly well and would swallow every contact in silence; if the probe fails the
switch goes back off instead of sitting on while nothing is written.

The panel is mostly status, and deliberately: every part of this runs on another
machine and on a sync client OpsLog cannot see, so "it is not working" has to be
answerable from the settings page — which PCs are in the folder, when each last
logged, what is waiting unread.

Four tests on the apply path, the middle two being the ones that matter: an edit
made on the other PC lands on the copy already here, matched on the contact
itself, instead of becoming a second row — that is what makes the no-backfill
decision safe — and a contact with the same station on another band stays a
separate contact.
2026-08-17 01:34:00 +02:00
rouggy 747c2b9105 feat(lookup): read QRZ's island reference and fill the IOTA award
QRZ's XML carries <iota>EU-048</iota> for an operator on an island, and OpsLog
read past it. Wired end to end: the provider, the lookup cache (a column, like
web and zip before it), and the entry's award references.

It matters more here than the same field would for another award. There is no
live "who is on an island right now" feed anywhere — POTA has one and that is
what OpsLog matches spots against; SOTA has one behind conditions; IOTA
publishes only static lists. So the callbook record is the practical source, and
it is known BEFORE the contact is logged, which is when a reference is useful.

The reference goes in as an IOTA award reference, exactly as one picked by hand,
so the existing path carries it to the qso.iota column on save.

Two limits, both deliberate. A reference the operator typed or picked WINS: a
callbook entry can be years out of date, and the operator in front of the radio
has just been told where the station is. And the value must look like an IOTA
reference — two letters, a hyphen, three digits — because writing anything else
into the award makes a reference no list contains, which counts for nothing and
has to be found by hand later.
2026-08-16 23:22:37 +02:00
rouggy 8683a450a7 feat(psu): switch a Modbus RTU bench supply from OpsLog
The manufacturer's document arrived, so this is no longer guesswork: 9600 8N1,
function codes 03 and 06 only, and a register map with the output on/off at
0x0001, the measurements at 0x0010…0x0013 and the set points at 0x0030/0x0031.

ONE REGISTER IS WRITTEN — 0x0001, the output. The map also exposes the voltage
and current set points and the three protection trip levels as writable, and
none of them belong to a logbook: a wrong value there is 30 V where a radio
expected 13.8, or a trip level lifted on a supply feeding an amplifier. They are
read and displayed instead, next to the measured values, which is also how an
operator sees at a glance that the supply is on and the radio is drawing nothing.

The wire layer is tested where it can be. CRC-16/MODBUS is pinned against its
published check value — the CRC of "123456789" is 0x4B37 — which fixes the
polynomial, the initial value, the reflection and the absence of a final xor all
at once; the rest of the protocol is checked frame by frame against the manual,
including the byte order of the CRC, an exception reply told apart from a broken
line, and a reply from another slave on the bus refused. The write echo must
match the value sent: it is the only confirmation the output really switched,
and accepting the frame without it is how a radio ends up dark behind a green
light.

Framing follows the manual's own rules: 3.5 character times of silence between
frames (4 ms at 9600), and a frame is over when the line falls quiet — Modbus
RTU has no terminator, and a serial read that times out returns (0, nil) here,
so the reader is built on a deadline and a quiet-time rather than on an error
that never comes.

Untested against hardware — nobody here has the supply.
2026-08-16 13:15:55 +02:00
rouggy 5bf7eb45d7 feat(rotator): native SPID / AlfaSpid, so PstRotator can go
An operator with a tower at each end and an AlfaSpid on both wanted OpsLog to
talk to them directly. Multiple rotors were already there; the missing half was
the protocol.

Rot2Prog and Rot1Prog, over the controller's own COM port. The byte layout is
in the package doc and pinned by table tests against Hamlib's spid.c, the
reference implementation — including the one trap this protocol has: digits go
out as ASCII and come back as raw bytes. Send raw and the controller ignores
you; read as ASCII and every heading is wrong by a constant nobody would
recognise as such.

Two things the UI has to get right because they cannot be detected: the dialect
(different reply length AND baud rate) and the baud list, which for a SPID is
600 or 1200 — offering the usual 4800-and-up would have left the controller
permanently mute. Both are handled: picking SPID sets serial transport, 600
baud and Rot2Prog, and the baud dropdown changes to the rates these use.

The connection test reads a status rather than moving anything, so a wrong
dialect shows up there as a reply of the wrong length instead of as an antenna
that behaves oddly an hour later.

Untested against real hardware — I have none. The frames are pinned; the
controller is the only thing that can confirm the rest.
2026-08-15 09:22:51 +02:00
rouggy 26b8dade20 feat(relays): generic HTTP relay board, and point band switching at it
An operator was about to build band→relay mapping into the custom URL rows.
It already exists, and better: relay auto-control has a per-relay "band" rule
driving webswitch, KMTronic, Dingtian, Denkovi and USB boards. It holds state
so a relay is only commanded when its wanted position changed, reads the
boards live before the first apply so it does not re-command one already in
place, and carries per-relay labels. A URL fired on a band change has none of
that, and would have been a second definition of "which antenna on which band"
— the drift that cost us a week on counties.

So the gap was only the hardware: a hand-made switch is none of the five named
types. It is now the sixth, "HTTP relay": an ON URL and an OFF URL, either as
one pattern with {relay} substituted, or one pair per relay. The per-relay
form is the reason it exists — these boxes often have URLs with nothing in
common between channels (…/FF0101 and …/FF0201), which no pattern can express.

Status is remembered rather than read: most have no endpoint worth trusting.
The cost is stated in the code and the panel — after a restart every relay is
re-commanded once, which is harmless on a board with no memory and far better
than assuming an antenna is already selected.

And the Connections panel now says so, exactly where the wrong choice is made:
picking a band change with a URL transport shows a note pointing at Station
Control. The URL transport stays — a lookup pushed to a webhook or a QSO to a
dashboard is an event, not a state, and Station Control has no place for it.
2026-08-15 02:23:03 +02:00
rouggy 6c2c1f106a feat(udp): custom outbound messages — a trigger, a template, UDP or a URL
The hard-coded emitters each speak one published format at one fixed moment.
This is the escape hatch, and it exists mainly for antenna switches: they are
driven by a URL, and the band change is the trigger they want.

Four triggers, each carrying its own field set: band change (the RADIO's band,
not the entry form — a switch follows the rig, not what is being typed), QSO
logged, rotator command and callsign lookup. The panel prints the fields the
selected trigger can fill, which is the point of the whole thing: a placeholder
the trigger does not carry renders as nothing, and without the list an operator
writes {freq} on a band change and has no way to learn why the switch never
moved.

Three things the panel cannot show, handled here:

  - Escaping. A URL needs every value percent-encoded; a UDP payload must not
    be touched. The first portable callsign, F4BPO/P, puts a path separator in
    the middle of a query string otherwise — it works on the bench and fails on
    the air.
  - Blocking. An HTTP call to a switch that is unplugged would otherwise sit
    for the operating system's timeout, on the path of a band change. It runs
    in the background with a 3 s limit.
  - Silence. A switch answering 404 after a firmware update fails exactly like
    success looks from here, so the status code is logged, throttled per row.

The rotator trigger hooks the COMMAND rather than the SP/LP buttons, so the
compass and a spot click fire it too, and the path ("SP"/"LP") is passed
through because an azimuth alone cannot say which was taken — 137° is short
path to one station and long to another.

Credentials in a URL are stored as typed. That is the operator's call, on the
grounds that this is LAN gear, and the hint in the panel says so.
2026-08-15 01:39:16 +02:00
rouggy 7470ebf47e feat(qsl): set the QSL route per direction from the QSL Manager
The paper form had one "Via", which since the QSL_VIA split writes
QSL_SENT_VIA. QSL_RCVD_VIA had no way in at all outside the per-QSO editor —
a field with a column, an import and an export, and nothing to fill it.

Each direction now has its own route dropdown, sitting with the status and
date it belongs to: how the card was sent, beside Sent; how it came back,
beside Received. Both apply to the whole selection, which is the shape of the
job — a stack confirmed in one go usually arrived the same way, a bureau
delivery being exactly that, and doing it one QSO at a time in the editor was
the only option before.

Both store the ADIF enumeration (B/D/E), and neither touches QSL_VIA — that
holds the manager's callsign and belongs to the QSO, not to a stack of cards
being confirmed together.

The two columns were already available in the paper list: it renders through
RecentQSOsGrid, which gained them with the field itself.
2026-08-14 16:32:12 +02:00
rouggy a9e3a159d9 feat(cluster): Chase New — a panel for what is new and audible here
PSK Reporter tells you what is actually being decoded in your region, which
is a larger set than what somebody chose to spot: nobody spots the FT8 caller
running ten watts from a rare square.

Almost all of it existed. The MQTT payload already carries frequency, mode,
transmitter and both grids; the watcher already drops any report collected
further than NearKm from the operator, which is exactly the question worth
asking — the station is being heard HERE, not in Japan; and with grid chasing
on the subscription is already every band, filtered at the broker by receiver
square, measured at 0.2 to 1.2 messages a second. This reads messages that
were arriving and being discarded.

"New" is not decided here. Every spot goes through ClusterSpotStatuses, the
same function the DX cluster grid uses and the same cached index, so the two
panels cannot drift apart the way the county columns did. Cost per message is
map lookups behind an option cached in an atomic, because the MQTT goroutine
must never wait on the settings store.

The option is its own, not nested under grid chasing: chasing squares and
chasing entities are different wants, and the feed now has three consumers,
any one of which brings it up and none of which cuts the others loose when it
goes down.

The panel says "digital modes only" in its footer. An empty list has to mean
"nothing new on FT8/FT4/JS8 near you", not "the band is dead" — it will never
show a new entity on CW.
2026-08-14 15:20:18 +02:00
rouggy 4e88bdfaa7 fix(qsl): separate the QSL manager from the routing method (#16)
QSL_VIA is the manager. QSL_SENT_VIA and QSL_RCVD_VIA are the ADIF "QSL Via"
enumeration — B bureau, D direct, E electronic, M manager (import-only) —
and say how a card travelled. OpsLog had one column for all three:

  - the import folded QSL_SENT_VIA into QSL_VIA whenever QSL_VIA was empty,
    which is exactly a Log4OM export (it defaults QSL_SENT_VIA to E), so
    OE6CLD saw "E" everywhere OpsLog shows the manager;
  - QSL_RCVD_VIA was listed in adifPromoted with no column behind it, so it
    was not stored, not kept among the extras, and not exported — dropped
    outright on import;
  - neither was ever written on export, so an import followed by an export
    destroyed both;
  - and OpsLog polluted the field itself: the QSL Manager panel wrote
    "Bureau" / "Direct" / "Electronic", in full words, into QSL_VIA.

Two columns added (migration 0027), carried through the five places a
promoted ADIF field has to touch, with round-trip tests pinning the reported
case. The QSL panel now offers Bureau / Direct / Electronic for each
direction and stores the enumeration; the manager field is labelled as the
manager and holds only that. M is kept when a file gives it and never
written back out.

Existing logs hold a mixture of the two in one column. The repair is offered,
not performed: the count is shown once per log with a plain question, and a
"no" is remembered. It moves only where QSL_SENT_VIA is still empty, and only
values that normalise to the enumeration — a manager is a callsign and can
never be one of those six words, which a test pins against real manager calls.
2026-08-14 11:50:25 +02:00
rouggy 30143b01bf fix(counties): make the cluster and the entry panel agree on a US county
An operator asked why K1SEI showed "Middlesex" in Info (F2) and "Lower
Connecticut River Valley" in the cluster. Two sources: the entry panel has
a callbook answer, a spot carries only a callsign so the cluster derives one
from the FCC licence ZIP through GeoNames — and GeoNames has followed the
Census in replacing Connecticut's counties with the 2022 planning regions.
No award, callbook or log uses those, so every CT station matched nothing:
new county for ever, and counting toward nothing.

Measured against a full ULS import (1 556 444 US callsigns), 23 223 resolved
to a name the USA-CA reference does not contain. Three causes, three fixes:

  - Spelling. "City and County of San Francisco", "Baltimore (city)",
    "Nome (CA)", plus counties renamed since the award list was drawn
    (Kusilvak, Oglala Lakota, the Valdez-Cordova split) and Alaska's four
    "X City and Borough", whose reference codes read "JUNEAUCITYAND" because
    the county-type suffix strip eats the wrong end. Normalised in
    award.USCountyKey, which both sides already go through. 7 515 callsigns,
    no re-download needed.

  - Doña Ana, NM shipped into the reference as "NM/DO̱AANA" — mangled by a
    non-UTF-8 CSV line, a code nothing could ever produce, so that county was
    unwinnable and silent about it. Row repaired, cntygen now refuses such a
    line, and a test makes every one of the 3 102 references reproduce its own
    code from its own name.

  - Connecticut. A planning region is drawn from towns in several counties, so
    no name maps to a name — only the ZIP can resolve it. cmd/ctzipgen builds
    the table from the Census 2020 crosswalk, filling PO-box-only ZIPs from the
    nearest resolved centroid; all 11 ZIPs GeoNames still labels with a real
    county agree with the result. 15 037 callsigns, applied at import, so the
    store now carries a rules version and Settings says when a re-download is
    needed.

Alignment itself is the last piece: a spot now shows the county the station is
logged with when we have one, and falls back to the ZIP-derived county only for
stations never worked.
2026-08-14 00:42:27 +02:00
rouggy 380472bda8 feat(qsl): copy a card design from another profile
A second profile is usually the same operator with a different rig or a
different locator — same callsign, same cards. Having to redraw a design
because the antenna changed is work nobody should do.

The designer now lists the designs the active profile cannot see and copies a
chosen one into it. Designs it already sees — its own, and the shared ones —
are left out: they are not something to copy.

A COPY, not a move or a share. The original profile keeps its design untouched
and the duplicate is free to diverge, which it usually will: a second profile
exists because something differs, and that something often ends up on the card.

The PICTURES are copied too, and that is the part worth getting right. Stored
documents reference photos by name relative to the template's own asset folder,
so duplicating the JSON alone would point the new design at files that are not
in its folder. It is validated against its own folder afterwards, and a failure
rolls the row back rather than leaving a design whose pictures are missing.
Sharing the source's folder was the other option and a worse one: deleting
either design would then have emptied the other.
2026-08-13 18:08:14 +02:00
rouggy 784d809290 fix(rotator): put the SP/LP readout inside the compass itself
It went under the Station Control rotator; the compass that wanted it is the
docked one beside the entry strip. Both are the same RotorCompass, so the
readout moves INTO that component: every compass in the app carries it, and no
caller draws its own.

The bezel already showed the short path as a red marker, but a marker is a
direction, not a number — and the numbers in the status bar are 10px, which is
what prompted this.

Clickable only when the caller passed onGoto, since a compass rendered without
one cannot turn anything and a button that does nothing is worse than a label.
2026-08-13 11:45:49 +02:00
rouggy 8e49d37cbd feat(lookup): optional QRZ nickname as the logged name
QRZ publishes <nickname> — the name an operator goes BY on the air — and
OpsLog was composing fname + name instead. "Bob" is what belongs in a log;
"Robert J Smith" is what belongs on a licence.

QRZ only, and deliberately so: HamQTH's <nick> already fills the Name field
that way, so the same switch there would toggle a behaviour it has no way to
turn off.

A published nickname is optional, so an empty one falls through to the
registered name. That is the whole point of it being a fallback rather than a
swap, and it is what the test pins — a blank nickname must never blank the
name.
2026-08-13 10:49:52 +02:00
rouggy 1605e30f60 feat(appearance): four QSL categories, each scoped to chosen channels
Replaces the four fixed rules with the model Logger32 uses and the operator
asked for: Worked / Confirmed / QSL sent / To be sent, and inside each, which
channels count — paper QSL, LoTW, eQSL, QRZ.com. No ticks means every channel,
because a rule the operator has not narrowed must not quietly become a rule
about nothing.

"To be sent" is FIRST in the order, and that is the substantive decision here.
A contact can be confirmed on LoTW and still owe a paper card; the colour an
operator scans for is the one meaning "something is still owed". Placed after
"confirmed" that row goes green and the card never gets printed.

"Worked" is the catch-all — nothing sent, nothing requested, nothing back — and
is off by default, since turning it on paints every remaining row.

R and Q both count as owed: ADIF says requested and queued, and both mean the
card has not gone out.
2026-08-13 09:46:52 +02:00
rouggy f698096f53 feat(bandmap): mark LoTW users, with the badge the cluster already uses
Logger32 draws a green square for a LoTW user and an X for a worked one. The
square is the part worth copying; the way it is drawn is not.

Colour on this map is already spoken for — it carries the entity status, and
the left edge of each pill stacks the new-park / new-county markers. Adding a
green fill for LoTW would put two unrelated meanings on one channel, and an
operator would have to work out which green meant what. So it is the same "L"
badge the cluster list draws, in the same muted blue, for the reason written
there: whether a station uploads to LoTW says nothing about whether the spot is
worth chasing.

Worked stations already read as worked here — the pill goes grey and the
status markers speak — so no X is needed.

Switchable in Settings → Appearance, and on by default: a `configured` flag
distinguishes "saved with this off" from "saved before the option existed", so
turning it off sticks instead of being undone by the next default.
2026-08-13 08:41:38 +02:00
rouggy 4ffdfc2548 feat(appearance): left stripe by default, with fill and strength as choices
The first version filled the whole row at 24%, and in a real log that meant
every row was painted: nearly every contact has SOME QSL state, so colour was
present everywhere and stopped being information — a striped background with
the data behind it.

The default is now a 3px stripe down the left edge. Same signal, nothing lost
to read it. A filled row is still offered, with a strength slider, for
operators who want the block — and the rule cards in Settings preview whichever
is chosen, so the decision is made by looking rather than by imagining.

Drawn as an inset shadow rather than a border: a border would shift the cells
three pixels on coloured rows only, and the columns would stop lining up.

Style and strength are normalised rather than rejected — a value out of range
is a slider that got away, not a reason to reset the operator's colours.
2026-08-13 01:42:08 +02:00
rouggy 858c04d267 feat(appearance): colour log rows by QSL status, and fix UDP QSO numbering
New Settings → Appearance section: whole-row colouring in the log grid driven
by QSL / LoTW state, each rule with its own colour from a palette or a free
picker. What Logger32 does, with one difference that matters — the colour is
applied as a 24% tint, not a fill. Logger32's grid is white; this one is dark,
and a saturated user-picked colour behind white text is unreadable at exactly
the moment the operator is scanning for what still needs sending.

Rules are ORDERED and the first match wins, because a contact is usually
several of these at once: one confirmed on LoTW and by card is confirmed, not
"sent, awaiting reply". The order lives in the data so the panel can show the
rules in the order they actually apply, numbered.

The colour is interpolated into a CSS color-mix(), so anything that is not
plainly #rrggbb is refused on the way in and falls back to the default.

Also fixes the QSO number column, which was empty for contacts logged from
WSJT-X: that path inserts through the repo directly and never reached AddQSO.
Rather than chase each of the remaining bulk-insert paths — the POTA hunter
import and the LoTW/QRZ "add what I was missing" passes, which insert OLD
dates and so shift every number after them — the index now checks its length
against a COUNT and rebuilds when they disagree. One indexed count beats
remembering to invalidate in a place that does not exist yet.
2026-08-13 01:06:21 +02:00
rouggy 62256942a1 feat(log): QSO number column, oldest contact = 1
Not the id: the primary key follows insertion order, so importing an old ADIF
gives the oldest contacts the highest ids. This is a rank over qso_date.

Computed over the WHOLE log, not the query result — ranking inside the result
would renumber every contact the moment a filter is applied, and QSO #1 would
change identity as the operator typed.

Held as one id-to-rank map, built from a single ordered id query and dropped
with the other derived indexes when the log changes. A contact logged from the
entry form is the newest, so it takes the next number without rereading the
log: AddQSO deliberately avoids full invalidation because a contest run would
pay for it once per QSO. A contact entered with an OLDER date belongs in the
middle of the order, so there the map is dropped and rebuilt rather than
mis-numbered.
2026-08-13 00:31:38 +02:00
rouggy 2f5fd63afd fix(lookup): read the fields the providers were already sending
Audit of every field a lookup returns against what OpsLog can store, prompted
by a report that HamQTH was not fetching the email.

The email was never broken: pinned now against a captured HamQTH answer, it
parses and reaches the QSO. The reported callsign simply has no public address
on HamQTH, which the empty field could not distinguish from a fault — so the
"[email protected]" placeholder is gone. A greyed-out sample address in the one
field an operator checks to see whether the lookup found one reads as a value.

Real gaps found and closed:

- web: the qso table has had the column since migration 0003 and no provider
  mapping ever read it. HamQTH sends <web>.
- picture: Result.ImageURL was documented "QRZ only" because HamQTH's element
  is <picture>, not <image>. It was there all along.
- zip: sent by both providers, read by neither.
- adr_name: used only as a fallback when a record carries neither nick nor
  name. Deliberately NOT preferred over <nick> — a log wants the name the
  operator goes by on the air, "Igor", not "Igor Vladimirovich Getman".

The parse tests use a real captured payload, including the stray <div> advert
the server injects into its own XML.
2026-08-12 17:34:45 +02:00
rouggy 7d33379fe1 feat(cluster): feed the locator store from PSK Reporter, filtered at the broker
The store shipped without its main source. Locators came only from this
station's own WSJT-X decodes, which is what the whole MQTT discussion was
about.

PSK Reporter now feeds it through a new OnGrid callback, fired before any
geographic filtering: what the store wants is "which square is this callsign
in", and that is true whoever happened to hear the report.

The subscription filters on the RECEIVER's square, a level the v2 topic
exposes. Measured on the live feed: the four opening bands unfiltered are 83
messages a second, of which roughly one in a hundred survived the NearKm test
that already existed here — the rest was received, TLS-decrypted, JSON-parsed
and discarded. One ring of squares is 0.2 to 1.2 a second.

By square rather than by DXCC, which was the obvious alternative: one country
measured 1.2 messages a second (OH) against 72.5 (K) on a single band, because
a DXCC can be a continent. By square the same measurement is 0.2 to 1.2, so the
load follows distance — what the feed is actually about — and is the same for
every operator.

Grid chasing subscribes with the "+" band wildcard, so one subscription per
square covers every band instead of one per band per square.

The store gains a source column (decode | mqtt), migrated in place on an
existing file.
2026-08-12 17:16:47 +02:00
rouggy 1b32b1ddec feat(antenna): per-band tune frequency for Ultrabeam and SteppIR
The band buttons in Station Control tuned to a fixed mid-band frequency baked
into the frontend. An operator who lives in the CW segment, or in the FT8
window, got the antenna resonant somewhere he never operates and had to nudge
it every time. The SteppIR's own controller has a Frequency (KHz) column per
band for exactly this; this is that column.

Stored sparsely: a band with no entry uses its default, so nothing migrates
and an operator sets only the bands he cares about. Left empty the Settings
box shows the default as its placeholder, which makes clearing it the obvious
way back.

The value is checked against the band plan before it is kept, because it goes
to the antenna as a tune command — a lost digit (1450 for 20 m) or kHz typed
as MHz would send the elements travelling to a length wrong for the band the
operator is on, and on a SteppIR that journey inhibits transmit the whole way.
A rejected entry is logged and the band falls back to its default.

Resolution happens in the backend and rides on the existing status poll, so
the widget no longer decides where a band button goes and cannot drift from
what Settings shows. Its own table stays only as a floor for the first poll.
2026-08-12 08:58:01 +02:00
rouggy 65bbaa85f3 feat(antenna): three tracking modes for Ultrabeam and SteppIR
The follow loop only ever had one behaviour — re-tune once the rig moved
further than a fixed step. The SteppIR's own controller software offers three,
and operators arrive with that mental model: every frequency change, past a
threshold, or only on a band change. They are real operating trade-offs, not
preferences: "always" keeps resonance perfect at the cost of motors running
constantly (and on a SteppIR every move inhibits transmit while the elements
travel), "band" moves them a handful of times a day.

"Always" is implemented as the threshold mode with a 1 kHz threshold rather
than as a separate branch, so all modes keep the one deadband reference that
matters: the rig frequency last commanded for, NOT the antenna's own reported
frequency — a SteppIR flips its reported frequency between the commanded value
and its home value, which would re-issue a SET on nearly every poll and leave
the operator permanently unable to transmit.

Band mode compares against the band last COMMANDED for, not the rig's previous
band, so the first move after startup and any move made by hand still get
reconciled. It applies to the immediate spot-click path too: a click inside the
band the antenna is already resonant in moves nothing.

An unset or unrecognised mode resolves to the step mode, so every config
written before this option behaves exactly as it did.

Also routes the antenna settings block through t() — it was hardcoded English.
2026-08-12 08:31:44 +02:00
rouggy 92a5f30ac0 feat(db): fill in the distances nothing ever recorded
The published page derives a distance as it renders, which fixed the empty
column there but not the cause: the field is empty in the database, so it goes
out empty in every ADIF export and leaves the same gap in whoever imports it.

BackfillDistances computes it from the two locators for QSOs that have both.
Only where it is EMPTY: a stored distance came from the log that recorded the
contact, which knew the real positions, and two four-character squares are a
worse answer that must not overwrite a better one. Whole kilometres, for the
same reason the published column is.

In the Database panel rather than beside the county backfill, which lives under
US Counties: this one touches the whole logbook, whatever country the QSOs are in.
2026-08-11 19:49:03 +02:00
rouggy 78e99afc63 fix(ui): the opening badge must say what is open, not that something is
It read "6M OPEN" and kept the rest in a tooltip nobody hovers — an alarm with
no content, announcing that something was happening and refusing to say what.
Band, direction and typical distance are on the badge now: the three facts that
decide whether to point an antenna, all readable without touching anything. Out
of season gets a mark there too, since that is the one an operator must not
learn last.

The compass point is computed in Go and carried on the Opening rather than
derived in the UI. Sector() already gets the wrap round north right, and it got
it wrong once — a second copy of that arithmetic is how a badge ends up naming
the opposite direction to the log line beside it.
2026-08-11 17:58:51 +02:00
rouggy c4d90e6880 feat(station): drive the motorized antenna from its widget
It showed pattern, elements and Retract — everything except where the antenna
is pointed, which is the thing an operator changes most. Tuning it meant opening
Settings or moving the rig.

A button per band, taken from the bands configured in Settings rather than a
list invented here: a band dropped there cannot be clicked here. They point at
where people actually work, not the arithmetic centre — 20 m centres on 14175
and nobody lives there, 10 m spans 1.7 MHz of which the top half is empty.

Up and down move 25 kHz, which is also the finest tracking threshold: a smaller
nudge would be undone by the next poll while tracking is on. They work from the
ANTENNA's frequency, not the rig's, or walking the antenna across a band while
the rig stays put would be undone on the first press.

Tracking moved within reach because it is an operating decision — off to park
the antenna, on to resume — not something set up once. Its step only appears
when it is on: a threshold for something switched off is a question the operator
cannot act on.

MotorTuneKHz carries the CURRENT direction rather than resetting it. Both
controllers set frequency and pattern in one command, so a bare frequency would
silently drop a 180° or bidirectional pattern — a beam turning round is not an
acceptable side effect of clicking a band.
2026-08-11 15:45:02 +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 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 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 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 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