Every condition was joined by ONE global AND or OR. "2 m or 70 cm, in FT8, since
January" therefore had no expression at all: AND killed the two bands, OR let
every FT8 QSO through. The filter could ask simple questions and nothing else.
Rather than grow nested groups — a tree in the UI to answer something that is
nearly always "this field, any of these values" — the OR lives INSIDE one
condition and everything else keeps ANDing. Two operators, a comma-separated
value, no change to how the rest of the filter behaves.
Two edges that matter more than they look. An empty list matches NOTHING rather
than being dropped: dropping it would widen the result set, the opposite of what
someone typing a filter expects. And "is none of" wraps the column in IFNULL,
because raw SQL NOT IN discards NULL rows — a QSO with no band recorded is not
one of the listed bands, so it belongs in the answer.
SetMotorFollow went through SaveUltrabeamSettings, which tears the client down
and dials again. That is right when the transport changed and absurd for a
checkbox: the operator flipped tracking and watched the antenna disconnect and
come back, which on a remote controller is several seconds of a link that was
working perfectly.
The follow loop is a goroutine with its own stop channel and can be replaced on
its own — the connection underneath never knows. Only the two keys this call
actually changes are written, too: re-saving the whole block to change a
checkbox means re-normalising host, port, baud and bands, and every one of those
is a chance to alter something nobody asked to alter.
startUltrabeam now starts the loop through the same function rather than its own
copy. Two versions of "start the follow loop" drifting apart is how a step
change quietly stops taking effect.
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.
Club Log refuses with its ordinary web page rather than an error string, so the
new test reported a rejected login by pasting a 403 page — title, stylesheets,
navigation and all — into the status bar. The body now goes to the log, where a
real diagnosis happens, and the operator gets the one sentence there is to act
on: check the e-mail, the password and the logbook callsign.
Dropped the startyear=2099 filter with it, and that one matters more than it
looks. It was there to keep the reply small, but it was never verified against
Club Log's API — and Club Log answers an unrecognised request with the SAME 403
it uses for a refused login. An unverified parameter would therefore have made
every CORRECT password look wrong, which is precisely the failure this change
set out to end. The reply is capped at 4 KB and closed at once instead; the
status code arrives ahead of the body either way.
Band-opening detection had five entries in 0.24.5 — long paths, the PSK Reporter
feed, which bands, the densest sector, the region filter. Those are five steps I
took, not five things that happened to the operator, and the version read like a
work diary. Merged into one entry describing what the feature now does.
The 12 m mention went with them: it was added and removed inside this same
version, so it never existed as far as anyone reading this is concerned.
A PSK Reporter message says "X was heard BY Y". The watcher measured the
distance and bearing from the operator to X and stopped there, never asking who
had actually heard it — so a station 1400 km away, decoded by somebody in Japan,
counted as evidence. That proves the path from X to JAPAN and says nothing about
whether anything reaches this station. It is how a "2 m opening" came to be
announced out of a KX9X in the United States, and the operator was right to find
the callsign list absurd.
Reports are now kept only when the RECEIVING station is within 300 km. Far
enough to borrow the ears of a whole region — an opening reaches an area, not a
postcode, and waiting for a decode at one's own antenna is just working the band
— and close enough that the ionosphere doing something there is it doing the
same thing here. The transmitter still supplies the direction and the path
length, which is what was always wanted from it.
Also drops 12 m from the watched bands, at the operator's request: at this point
in the cycle it is open often enough that announcing it is a notification rather
than news, and a band that cries wolf costs the ones that do not. One fewer
subscription is also less traffic on a PC that pays for every message.
Two faults, both found from one field log: 70 000 decodes, 10 and 12 m plainly
open, nothing announced — and the two openings that DID fire named the wrong
direction.
evaluate() required EVERY distinct station in the window to fit inside one 90°
arc. That is the shape of a sporadic-E cloud and of nothing else. With 10 and 12
m open on F2 the reports arrive from all round the compass, the arc is 360°, and
the test can never pass — so the busier the band, the less likely an opening was
announced. Exactly backwards. It now finds the DENSEST sector instead, which
keeps the Es signature intact (a cloud still makes one direction dense) and lets
a real F2 opening be seen through the handful of neighbours who are always
there. Scattered-but-busy still reports nothing: the point was to stop demanding
global agreement, not to call every open band an opening.
Sector() averaged the two bearings arithmetically, so an arc crossing north was
labelled by its opposite: 353–61° averaged to 207° and went out as SW when it
was NE. Not a vague error — a reversed one, given to an operator who may turn a
beam on it. The detector has handled the 0/360 wrap since it was written; only
this label had not.
The only sign of a detection was a toast. It is gone in seconds, and an operator
who was tuning at that moment had no way back to it — for an event that lasts
hours and happens a handful of times a season, that is the wrong shape entirely.
The badge sits at the right-hand end of the status bar, before the clock: an
opening is a state of the WORLD, not of this station, so it belongs with the
time and the logbook rather than among the rig and amplifier chips.
Knowing when to go out was the real work. Nothing announces that an opening
ended, and the detector deliberately says nothing more about a band for 45
minutes after announcing it — right for a message, useless for a badge. So it is
inferred: every qualifying spot on that band pushes a deadline out, and when
they stop arriving the badge fades by itself. Fifteen minutes, comfortably more
than the detector's own twelve-minute window, so a quiet couple of minutes
mid-opening does not blink it off and on again.
Gated on the same distance floor the detector uses. Without that, a band busy
with short-range tropo would hold an Es badge lit indefinitely on spots the
detector itself had refused.
TestClublog checked that three fields were non-empty and returned "Ready — CALL
via EMAIL". Nothing was ever sent to Club Log, so a wrong password produced the
identical green message. It now signs in, through getadif.php because that is
the one authenticated endpoint that cannot change anything: a test must never
put a record into someone's log. A future start year keeps it from downloading
130 000 QSOs to prove a password, and a rejected login answers 403 before any
body arrives.
LoTW is two credentials doing two jobs and the button reported only the first.
Uploads go through TQSL signed by the certificate — the website password is
never involved — so a wrong one breaks nothing until the day confirmations are
downloaded, by which time nobody connects the two events. Both are now checked
and, more importantly, reported separately.
Also: the detector still refused 12 and 10 m while the PSK Reporter feed was
already subscribed to them, so those decodes were fetched and thrown away. The
rule was never "HF is out", it is "is an opening here an event" — 20 m being
open is the normal state of the band, 10 m opening is not.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
"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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.