Commit Graph
599 Commits
Author SHA1 Message Date
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 700b688fc0 feat(rotator): short and long path headings under the compass
The same pair already sits in the status bar, where it is 10px and several
operators reported not being able to read it. Repeated under the compass at a
size that can be read across a shack.

Clickable, like the ones in the status bar: a heading you can see and not act
on would be a step backwards from what is already there. Disabled and dashed
when no station is entered, rather than showing a heading to nowhere.
2026-08-13 11:34:00 +02:00
rouggy 03598cfaff feat(entry): NEW badge on the county in the entry strip
The same answer the Details tab already gave, moved to where the county is now
typed — a badge in a tab nobody has open while working a station is a badge
that does not do its job.

Debounced at 300 ms and keyed on state + county together: the field is typed
into as well as filled by the lookup, and the check queries the log. A county
name means nothing without its state, so both have to settle before asking.
2026-08-13 11:30:27 +02:00
rouggy ed517eb8d3 style(entry): County/CQ/ITU/DXCC above Comment and Note
The looked-up values now sit directly under the geography line they continue —
QTH, State, locator, then county and zones — and the free-text fields close the
block. Reading order follows what the lookup fills, rather than interleaving it
with what the operator types.
2026-08-13 11:28:11 +02:00
rouggy 1cfba19ed2 feat(entry): County, CQ, ITU and DXCC on the freed row
Comment and Note moving onto one line left the right column two rows shorter
than the left, and this fills it: county, both zones and the ADIF entity
number. All four are filled by the lookup and were only visible after opening a
tab — the wrong place for values an operator wants to SEE are right before
pressing Log.

County is a name, not a code ("St. Tammany Parish"), so it takes the flexible
width and the three numbers get fixed narrow boxes.

Cleared numeric fields store undefined, not 0. Zero is a real DXCC number and a
real zone, so an empty box has to stay empty rather than assert something the
operator did not type.

State stays up on the geography line beside QTH and the locator, which is how
the lookup fills them and how they are read.
2026-08-13 11:24:10 +02:00
rouggy b18f5c491e fix(entry): the State field was defined but never rendered
stateBlock was built and then not placed in the row — the field simply did not
exist on screen. Now between QTH and the locator, where it was meant to go.

Left column 210 → 230px, and Comment and Note move onto ONE line side by side:
two fields that are usually a few words each were taking two full rows of the
entry strip. That frees a row for whatever goes there next.
2026-08-13 11:04:23 +02:00
rouggy 741364bf99 feat(entry): narrower left column, and a State field
Name, Band, Mode and Country go from 300px to 210px, which moves QTH, Comment
and Note left by the same amount. The width is set in TWO places — the Name row
and the Band/Mode/Country stack below it — and they have to stay equal or the
two rows stop lining up, so both were changed together.

State goes between QTH and the locator: it is part of the same "where is this
station" reading, and it is what WAS and the US county work key off. Narrow,
since it holds two or three characters, and upper-cased on the way in like the
other coded fields.

The Name label in this layout was hardcoded English while its twin went through
t(); fixed while here.
2026-08-13 10:56:52 +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 b2382a6135 feat(cluster): configurable spot lifetime
Spots were bounded by COUNT alone, so on a quiet band a two-hour-old spot sat
on the band map looking like something to chase. Settings → DX Cluster now
takes a lifetime: presets at 5/10/15/30/60 minutes, or any value up to twelve
hours, and 0 keeps the old behaviour.

Spots are REMOVED from the shared list rather than hidden at render. The
cluster list, every band map and the counts all read that one array, so pruning
it once is what makes the setting mean the same thing in every view — the
lesson already paid for when the band map ignored the cluster's filters. It
also gives the memory back.

A spot whose timestamp will not parse is never dropped: an unreadable stamp is
a reason to distrust the clock, not to throw away the spot.

Swept every 30 seconds — close enough that a 5-minute setting is honoured, and
invisible next to the spot stream. Clamped in the backend as well as the UI.
2026-08-13 10:38:08 +02:00
rouggy f8fb57210f fix(cluster): hide worked must not hide what is new
Reported on BH2SWB and 4X9AA: both flagged NEW PFX, neither ever worked, both
vanished the moment "hide worked" went on.

The predicate hid a spot when worked_call OR status === 'worked' — and that
second one is the ENTITY's status, not the callsign's. So any spot in an entity
worked years ago disappeared, however new it was for something else.

The extra markers are orthogonal to the entity status; the band map already
says so in as many words. They now win: a new prefix, county, grid or park is
never hidden, whatever the entity says. A worked entity that is new for nothing
is still hidden, which is what the filter is for.

Extracted to spotIsWorked in lib/spotDisplay, beside the other rule the cluster
and band map share, rather than left inline in a predicate that has already
grown once.

This repo has no frontend test runner, so this is verified by build and reading
rather than by a test.
2026-08-13 10:34:15 +02:00
rouggy e5f79b18c2 chore: release v0.24.9 2026-08-13 10:15:02 +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 6ca808a6c9 fix(bandmap): the docked map was missing the LoTW prop
Two BandMap instances, and cleaning up a duplicated attribute took the prop off
the docked one as well as the copy. The multi-band tab drew the badge; the map
beside the entry form — the one actually in front of the operator — did not.
2026-08-13 08:46:29 +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 ef628e066a fix(bandmap): follow the cluster's filters
The band map received the raw spot stream filtered by band alone, so none of
the cluster's filters reached it: switching on "LoTW users only" changed the
list and left the panadapter showing everyone. The same was true of hide
worked, the spotter continent, and the status and mode chips.

The predicate is extracted and shared rather than copied — two views of one
spot stream disagreeing about the same spot is the fault lib/spotDisplay
already exists to prevent, and a second copy would have drifted the first time
a filter was added.

The BAND filter is deliberately excluded from the shared part: a band map's
band is its filter, and applying the cluster's would empty every map but one.
2026-08-13 08:26:00 +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 1348dc2177 chore: release v0.24.8 2026-08-13 01:26:40 +02:00
rouggy 57ac382f91 fix(appearance): colour rows queued for LoTW upload, not just paper cards
The "to send" rule only looked at qsl_sent, so a contact waiting to go to LoTW
— marked R, and the commonest not-gone-out-yet state in a digital log — matched
nothing and stayed uncoloured. It now covers any route still queued, and the
label says so.
2026-08-13 01:14:44 +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 82a49150d2 feat(cluster): persist learnt locators behind a "Chase new grids" option
The callsign->grid map died with the process. Every restart began with an empty
Locator column that took an hour of listening to refill, and everything learnt
the day before was thrown away.

internal/gridcache is its own SQLite file in the data directory, not a table in
the settings database: that one sits wherever the operator chose to put it,
often a synchronised folder, and a store that rewrites itself every minute has
no business there. Deleting the file costs a few days of listening and nothing
else.

Callsign is the primary key and the newest report wins — operators move, go
portable, go on expedition, and a stale locator is worse than none for grid
chasing because it reads as a square already worked. Entries not seen in two
years are pruned at open: that is the one way this cache can be actively wrong
rather than merely empty, and it is what bounds a store that would otherwise
only grow.

Only CHANGES are queued. The feeds repeat themselves, so writing every report
would put the whole stream in the batch instead of the news in it. Batches
flush on a timer and on shutdown — a restart is exactly when the cache is worth
the most.

Rotation is switched off while the store is attached: the cap would discard
callsigns the database still holds and the lookup would then miss what we know.
Age in the store is the bound instead.
2026-08-12 17:01:05 +02:00
rouggy bbddba6e74 chore: release v0.24.7 2026-08-12 14:45:19 +02:00
rouggy 5dd3532074 feat(cluster): withdraw the two spot display options
Hidden from the filter panel and forced off, without touching the machinery
behind them — it is correct and took several rounds to get right, so it stays
whole.

One flag in lib/spotDisplay does both jobs: readSpotDisplayOptions answers
false while it is down, which covers the band map, and App.tsx seeds its two
states from that same call instead of reading localStorage directly. Without
that second half an operator who already had "No colour on worked" saved would
have kept it applied with no switch left to turn it off.

The saved preferences are deliberately left in localStorage rather than
cleared, so whoever had either option on gets their choice back the day the
switches return instead of silently starting from off.
2026-08-12 10:57:04 +02:00
rouggy dd7b63c059 fix(awards): stack the reference validity dates, and prove the award fallback
Two dates side by side put each one in a half-width Field2, which spends 120px
on its label column — the second label overlapped the first input and the row
ran off the panel. The comment ten lines above says exactly this about the
group/subgroup fields; one per row, as everything else in this editor.

Also shows a far-future end date as open-ended. RDA carries 9999-12-31 as "no
end" and printing it back reads like a real deadline.

The claim that an empty per-reference window inherits the award's is now a
test rather than a comment: a QSO before the award's own ValidFrom does not
count for a reference that has no window of its own, and a narrower reference
window still applies on top.
2026-08-12 10:51:12 +02:00
rouggy 8fc6673611 fix(awards): apply the per-reference validity window
References are not forever: a park is delisted, a district merged, a castle
loses its number. A QSO made while the reference existed is a valid contact
and must keep counting; one made afterwards must not.

The JSON format did not need to change — ValidFrom/ValidTo were already on
awardref.Ref, already columns in award_references, already round-tripping
through export and import. They were simply never read: awardRefMetas dropped
them on the way into the engine, so nothing downstream could enforce them.
This carries them through and checks them in keepRefs.

Empty means the award's own window governs, which inScope already enforces for
every QSO in the award. That fallback is deliberately NOT duplicated per
reference — two places enforcing the same dates is two places for them to
disagree. The editor shows the award's dates as the hint under the boxes so
the operator can see what empty inherits.

Dates are compared as ISO strings rather than parsed times: the stored shape
is "2006-01-02", lexical order on it IS chronological, and this cannot fail on
a malformed value the way a parse can — a reference with a typo in its window
keeps counting instead of silently vanishing from an operator's totals.

The Explain trace names the date and the cutoff, because "did not count" on a
contact the operator remembers making is exactly when they need to be told it
is the reference that has a window, not their log that is wrong.
2026-08-12 10:43:23 +02:00
rouggy d33291b521 feat(awards): wire up the single-award export
Only the whole-catalogue bundle was reachable from the UI, so sharing one
award meant handing over every award you have — including the ones you edited
for yourself.

Nothing new was needed underneath: ExportAward(code) already existed and
already reads the definition AND its references from the database rather than
from the embedded catalog, which is the point (a WAPC is only worth sharing
because of the province list and city regexes the operator added). The English
and French strings existed too. Only the button was missing.

Kept distinct from the catalog publish next to it: that one stamps a version
and clears user_edited to seed a release, this one is a plain share and leaves
both alone, so the recipient's copy is correctly marked as someone else's work
instead of a pristine built-in that future catalog updates would overwrite.
2026-08-12 10:23:55 +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 99d903eb44 fix(pgxl): stack the amplifier meters two by two
The card is two grid columns wide and sits beside a tall neighbour in Station
Control, so a single row of four bars left the height unused and squeezed each
bar into a quarter of the width. Two rows of two fill the room already there
and double the resolution of every bar.
2026-08-12 07:50:11 +02:00
rouggy 13515c58c0 fix(pgxl): draw the amplifier card's meters from the amp when there is no Flex
The PowerGenius XL card gated its whole meter row on flex.amp_available, so
on any station without a FlexRadio it rendered OPERATE and the fan selector
over an otherwise empty two-column card — reported from a TS-590 station.

Nothing was missing from the backend. internal/powergenius already parses
forward power, drain current, VSWR and temperature out of the GSCP status
frame, and the docked AmpWidget already prefers the radio's meter stream and
falls back to those. Only the full card never learnt the fallback.

Same source preference as the widget, and the same two exclusions: peakfwd
and peakid are latched maxima that are never reset and survive in the
last-known status after the amp disconnects, so the plain readings are used
and gated on the transmit state instead of freezing on the last over.
2026-08-12 07:40:45 +02:00
rouggy d4f23a52af fix(details): QSL via gets the width, QSL message gives it up
Width should follow use, and these two are nowhere near equal: a manager's
callsign is typed constantly, a QSL message almost never. The message held 7
columns of 12 for text most operators never write, while the field beside it —
often a long "via" instruction — was the one running out of room.

Swapped, so QSL via takes 7 and the message 5. Also puts them in the order they
are reached for.
2026-08-11 21:35:41 +02:00
rouggy f7d2de0777 chore: release v0.24.6 2026-08-11 21:14:59 +02:00
rouggy 210a99983e fix(bulk): a group missing from a hand-written list never rendered
The dialog walked a hand-written GROUPS array. "The contact" was added to the
fields and not to it, so mode, submode and RST existed, passed every check I had
written, and could not be picked — the same shape of fault as the missing column
mapping, one layer further out. Two lists that must agree, with nothing making
them agree.

GROUPS is derived from the fields now, in declaration order, so adding a group
is adding a field. A group with no translation falls back to its own name rather
than rendering an empty heading: a missing label should look untidy, not
invisible.

Also stacks the DX sunrise/sunset in the band-slot header. Side by side it cost
about 150 px of a row that also carries the callsign, the badges and the band
grid, and it was what pushed that row onto a second line. Two short times one
above the other cost a fraction of it and no extra height — the row is already
taller than one line of text. The "UTC" label goes to the tooltip: the times are
monospaced and always UTC everywhere in OpsLog, so it was spending width to
repeat a convention the operator already lives by.
2026-08-11 20:46:59 +02:00
rouggy 75a2f73992 fix(distance): compute it where QSOs are made, not in a settings button
The button was the wrong shape twice over. A maintenance chore does not belong
beside the options an operator actually chooses, and nobody should have to be
told their log is missing a field before it gets filled in.

So the distance is computed where QSOs come from: on the logging path and on the
ADIF import. fillDistance is its own function rather than part of
applyStationDefaults, because the import only applies those when the operator
ticks a box — and a distance is not a station default. It is derived from the
QSO's own two grids and is true whatever was chosen about profile fields.

What is already in the log is handled by a one-time migration at startup,
recorded by a settings key. In the background: on a large log over a remote
MySQL that is thousands of row updates and startup must not wait for a tidy-up.
Marked done only on SUCCESS, so a run cut short by a closed program tries again
next time rather than leaving half the log filled for ever.

An imported value still wins. It came from the log that made the contact, which
knew the real positions rather than two four-character squares.
2026-08-11 20:14:27 +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 102097c5c4 feat(bulk): mode, submode and RST are repair fields, so allow them
They were excluded as "per-QSO", alongside callsign and date. That rule confused
two different things: bulk edit is not for describing QSOs, it is for REPAIRING
a batch of them — an import that mapped every contact to SSB, an ADIF that
carried no MODE at all. Refusing because a hundred rows should not normally
share a value left the operator editing a hundred rows by hand.

Setting the mode CLEARS the submode. A submode belongs to the mode it was
recorded under; left behind it contradicts the new one, and "FT8 with a submode
of USB" is not a thing — worse, the submode is what most ADIF readers believe.

Band is still refused on its own, and that half of the rule stands. It travels
with the frequency through BulkSetFrequency, which writes the pair: a band
contradicting its own frequency is invalid ADIF, and every export would carry
the contradiction out into the world. Callsign and date stay out too — they
identify the contact rather than describe it.
2026-08-11 19:36:56 +02:00
rouggy 839cf0aafd chore: release v0.24.5 2026-08-11 18:19:54 +02:00
rouggy 9fd801ea69 fix(bandopen): stop subscribing to a band nobody watches, and drop the toast
A saved band selection outlives the code that made it. 12 m was removed from the
watched set, but every operator who had already enabled the watch kept
subscribing to it — paying for a firehose whose messages the detector then threw
away. The stored list is filtered against the offered one now.

The badge appeared on the next poll rather than on the event, up to twenty
seconds after the announcement, so the two read as unrelated things that happened
to mention the same band. It appears immediately.

And the toast is gone. It said the same thing as the badge and vanished after
five seconds — an operator who was tuning when it passed had no way back to it,
which is precisely what the badge was built to fix. Keeping both announced an
opening twice and still lost it once.
2026-08-11 18:11:09 +02:00
rouggy 298c2fde44 fix(ui): "1.5kkm" — the opening badge showed a doubled unit
Abbreviating 1500 to "1.5k" and then appending "km" produced "1.5kkm", which
means nothing. Plain kilometres now.

Written correctly it would still have been wrong: "1.5k km" asks the reader to
do arithmetic to recover a number that was four characters long to begin with.
There was nothing to save.
2026-08-11 18:04:04 +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 91452cefc0 feat(filter): "is one of" — so a real question can be asked
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.
2026-08-11 17:07:32 +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 96c99f0ae6 feat(bandopen): a blinking badge that lasts as long as the opening does
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.
2026-08-11 13:48:26 +02:00
rouggy 9c33feecfa feat(ui): PSK Reporter chip in the status bar
Beside the rig and amplifier chips, before ON AIR, because it is the same kind
of fact they report: a link that is either up or it is not. Clicking it opens
the settings that control it, like the amplifier chips do.

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

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

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

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

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

Turning it off leaves the nodes alone: they may have been wanted for their own
sake, and removing a node someone is using is worse than leaving one they are not.
2026-08-11 12:48:29 +02:00
rouggy b93c67f3cb fix(webpub): the column search box must not be scrolled over
It was sticky inside the scrolling list, and the rows went over it: a menu item
carries its own background and its own stacking context, so it wins against a
sticky sibling however high the z-index is raised.

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

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

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

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

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

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

Also collapses the changelog: four separate shared-CAT entries were one thing
from where the operator sits, and all of them were far longer than the one or
two sentences this project asks for.
2026-08-11 10:59:01 +02:00