[feat](trx-logbook): work a contest, and record what came back #60

Merged
sjg merged 6 commits from feat/logbook-contest-and-qsl into main 2026-08-07 21:50:02 +02:00
Owner

Phase 5 of #54, on top of #59 (which it stacks on — the first two commits are that PR's, and the doc commits are the proposal, which had never been proposed for merge).

Contest working

The exchange goes on the contact: contest, serials both ways — as numbers and as words, because an exchange is often a zone, a section or a name rather than a serial. In the panel the contest and the serial sent stay between contacts, since they belong to the session rather than the contact just logged, and the serial counts on by itself so it is not retyped forty times an hour.

Cabrillo 3.0

ADIF cannot do this job: sponsors take Cabrillo and reject everything else. Its shape is not ADIF's either —

  • frequency in kHz below 30 MHz, a band designator above it (144, 432, 1.2G)
  • modes are CW, PH, FM, RY, DG — not ADIF's names
  • contacts written oldest first, as a contest log is read

The header cannot be derived from a log — how many operators, how much power, what the score is claimed to be — so it comes from the operator, with SINGLE-OP, LOW, ALL, MIXED behind it. Only the named contest's contacts go into the entry.

Confirmations

QSL, LoTW and eQSL states are held as ADIF's single letters (Y N R I Q V), and anything else is refused rather than written — a log that grew states of its own would be one no other program could read. A contact is confirmed when any one of the three says so: an award wants a card or an electronic match, not one of each, and counting them separately would tell the operator they were short of what they have.

A QSL column shows the tick; Confirm on a row records the card that arrived.

Bands worked

Contacts, distinct stations, and confirmations per band, ordered by wavelength as a band plan reads.

Verification

  • trx-logbook: 37 unit tests — the Cabrillo header, the QSO line carrying both sides of the exchange, band designators above 30 MHz, the mode letters, oldest-first ordering, a word exchange; plus per-band statistics, the contest filter, confirmation from any bureau, and the new fields surviving an ADIF round trip.
  • trx-frontend-http: 64 tests, two new — a contest entry exported with only its own contacts and the operator's header, and a QSL state outside the enumeration dropped while a lowercase valid one is taken.
  • tests/logbook.mjs grows the contest session (contest and serial kept, serial incremented, received exchange cleared), the Cabrillo link carrying its header, and confirming a contact showing in both the QSL column and the per-band report.
  • Workspace: 54 test targets pass, fmt and clippy clean.

Note on the docs

The plan document travels with this branch. It was written on a branch of its own that was never opened as a PR, and my earlier attempts to update it from the implementation branches were silent no-ops — the edits did not assert they had found what they were replacing. Fixed here, along with the API paths, which the plan still described as /logbook rather than the /api/logbook they were built under.

Phase 5 of #54, on top of #59 (which it stacks on — the first two commits are that PR's, and the doc commits are the proposal, which had never been proposed for merge). ## Contest working The exchange goes on the contact: contest, serials both ways — as numbers *and* as words, because an exchange is often a zone, a section or a name rather than a serial. In the panel the contest and the serial sent stay between contacts, since they belong to the session rather than the contact just logged, and **the serial counts on by itself** so it is not retyped forty times an hour. ## Cabrillo 3.0 ADIF cannot do this job: sponsors take Cabrillo and reject everything else. Its shape is not ADIF's either — - frequency in **kHz below 30 MHz**, a **band designator above it** (`144`, `432`, `1.2G`) - modes are `CW`, `PH`, `FM`, `RY`, `DG` — not ADIF's names - contacts written **oldest first**, as a contest log is read The header cannot be derived from a log — how many operators, how much power, what the score is claimed to be — so it comes from the operator, with `SINGLE-OP`, `LOW`, `ALL`, `MIXED` behind it. Only the named contest's contacts go into the entry. ## Confirmations QSL, LoTW and eQSL states are held as ADIF's single letters (`Y N R I Q V`), and anything else is **refused rather than written** — a log that grew states of its own would be one no other program could read. A contact is confirmed when **any one** of the three says so: an award wants a card or an electronic match, not one of each, and counting them separately would tell the operator they were short of what they have. A **QSL** column shows the tick; **Confirm** on a row records the card that arrived. ## Bands worked Contacts, distinct stations, and confirmations per band, ordered by wavelength as a band plan reads. ## Verification - `trx-logbook`: 37 unit tests — the Cabrillo header, the QSO line carrying both sides of the exchange, band designators above 30 MHz, the mode letters, oldest-first ordering, a word exchange; plus per-band statistics, the contest filter, confirmation from any bureau, and the new fields surviving an ADIF round trip. - `trx-frontend-http`: 64 tests, two new — a contest entry exported with only its own contacts and the operator's header, and a QSL state outside the enumeration dropped while a lowercase valid one is taken. - `tests/logbook.mjs` grows the contest session (contest and serial kept, serial incremented, received exchange cleared), the Cabrillo link carrying its header, and confirming a contact showing in both the QSL column and the per-band report. - Workspace: 54 test targets pass, fmt and clippy clean. ## Note on the docs The plan document travels with this branch. It was written on a branch of its own that was never opened as a PR, and my earlier attempts to update it from the implementation branches were silent no-ops — the edits did not assert they had found what they were replacing. Fixed here, along with the API paths, which the plan still described as `/logbook` rather than the `/api/logbook` they were built under.
sjg added 6 commits 2026-08-07 21:32:38 +02:00
[feat](trx-rs): keep a station log, and a layout to work the bands from
CI / lint (pull_request) Successful in 2m23s
CI / frontend (pull_request) Successful in 5m52s
CI / reuse (pull_request) Successful in 6s
CI / test (pull_request) Successful in 9m12s
18b2d0efe6
The logbook of issue #54, in the shape the proposal settled on.

A new crate, trx-logbook, holds the contact, the ADIF reader and writer, the
file, and the rules for telling one contact from two.  ADIF because it is the
only thing the ecosystem reads: LoTW, eQSL, Club Log, QRZ and every other
logger take it and nothing else, so a log that cannot write .adi cannot be
uploaded, confirmed or moved.  The reader is forgiving in the ways real files
are irregular -- lowercase tags, CRLF, missing header, unknown fields, a
declared length that is the only thing ending a value -- and carries what it
does not model through to the export, so a round trip does not strip what
another program wrote.

The file is JSON Lines, appended one line per contact.  A log is the one
thing here that cannot be regenerated, and the bookmark store's whole-file
dump would rewrite megabytes to log one contact and lose all of them if the
power went halfway; an append costs the record being written and no more,
which a test tears a line in half to prove.  Edits append revisions, deletes
append tombstones, and the file compacts when the superseded outnumber the
live.

The panel is its own tab and stands in every layout.  An entry opens with six
fields and no more -- frequency, mode, rig name, time, and the callsign and
locator of whatever decode it was started from.  A report stays empty: an FT8
SNR is not what was sent.  Times come from the server, because the browser
may be a phone in another timezone, and the panel says so when the two
disagree by more than a second.  Worked-before answers as a callsign is
typed.

A decode is not a contact, so the Log button on an FT8 or APRS row opens an
entry and logs nothing by itself.

The ham layout is the fifth operator layout, opening on the logbook with the
radio controls around it, offered only where the rig can transmit.

Two bugs found on the way, both in code written here: a frequency of a whole
number of megahertz ending in a zero rendered as a tenth of itself, in Rust
and in TypeScript alike, because trimming trailing zeros from "20.000000"
walks back through the point.  The API also sits under /api/logbook rather
than /logbook, so it cannot shadow its own page the way /bookmarks does.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
Phase 5 of the logbook: the exchange, the entry sponsors take, and the
confirmations an award counts.

Contest fields go on the contact — the contest, the serials both ways as
numbers and as words, and the zones — because an exchange is not always a
number: a zone, a section or a name goes in as written.  The serial sent and
the contest stay between contacts, since they belong to the session and not
to the contact just logged, and the serial counts on by itself rather than
being retyped forty times an hour.

Cabrillo 3.0 is written because ADIF cannot do this job: sponsors take
Cabrillo and reject everything else.  Its shape is not ADIF's either — the
frequency is kilohertz below 30 MHz and a band designator above it, the modes
are CW, PH, FM, RY and DG, and the contacts go oldest first, as a contest log
is read.  The header cannot be derived from a log — how many operators, how
much power, what the score is claimed to be — so it comes from the operator,
with single-op, low power, all bands and mixed behind it.

QSL, LoTW and eQSL states are held as ADIF's single letters, and anything
else is refused rather than written: a log that grew states of its own would
be one no other program could read.  A contact is confirmed when any one of
the three says so — an award wants a card or an electronic match, not one of
each, and counting them separately would tell the operator they were short of
what they have.

The bands report counts contacts, distinct stations and confirmations per
band, ordered by wavelength as a band plan reads.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
Issue #54 asks for a ham radio layout and an ADIF logbook, with no detail
behind either.  This is what they would be: what the logbook has to hold, how
it is stored, where ADIF is read and written, and which of the existing parts
each half hangs off.

Two things it settles before any code is written.  The log is append-only
JSON Lines rather than the whole-file dump the bookmarks use, because a
station with forty thousand QSOs would rewrite megabytes to log one contact
and lose the lot if the power went mid-dump.  And a decode is not a QSO: the
decoders only ever heard something, so a decoded callsign pre-fills an entry
and never writes one.

The layout is a fifth entry in the operator layouts that already exist,
gated on the rig being able to transmit.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
Three answers from the issue, folded into the proposal.

The logbook is a panel of its own rather than a strip on the radio page, and
it stands in every layout: a log can be kept without adopting the ham layout,
and read while another is selected.  The ham layout is then the arrangement
that starts from it, with the radio controls around it.

Prefill is exactly six fields — frequency, mode, rig name, time, callsign and
locator — and nothing else.  A signal report in particular stays empty: an
FT8 SNR is not what was sent, and prefilling one would put a number in the
log that nobody exchanged.  The station's own callsign and locator are not
per-entry fields at all; they are station identity, shown once at the top of
the panel and written into the QSO from configuration.

The file format was left to me.  ADIF stays, because it is not one option
among several: LoTW, eQSL, Club Log, QRZ and every other logger read it and
nothing else, so a log that cannot write it cannot be uploaded, confirmed or
moved.  Nothing on disk is ADI regardless — the store is JSON Lines.  The
second format is Cabrillo 3.0, which ADIF cannot replace: contest logs are
submitted in it and rejected in anything else.  It lands with the contest
exchange fields, since without a serial or a zone it has nothing to write.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
The clock is the server's, as asked: it is the machine at the radio, where
the browser may be a phone in another timezone with a clock nobody checked.
When the two disagree by more than a second the panel says so, rather than
logging a time the operator did not expect.

The rest, decided against how logging is actually done:

One station log, not one per rig.  DXCC, WAS and LoTW count the callsign, not
the radio, and a station worked on the second rig is still worked.  The rig
goes on the QSO as MY_RIG.  Station location does follow the rig, though —
these rigs can be in different places, so MY_GRIDSQUARE comes from the one
that made the contact, which is what LoTW's station locations expect too.

The operator is a per-QSO field set once per session.  ADIF separates the
callsign used on the air from the person at the key, and multi-operator
stations rotate people through one station callsign.  It defaults from the
configured callsign, so a single operator never touches it.  It cannot come
from the session: the auth roles are control and rx, with nobody's name on
them.

The log file is configurable, defaulting to the user's data directory.
Bookmarks sit in the config directory because they are settings and decode
logs in the cache directory because they are disposable; a QSO log is
irreplaceable, and cache directories get swept.

Import collisions match on callsign, band, mode and a two-minute window.
Loggers rarely agree to the second on the same QSO — one stamps the contact,
the other the entry — so an exact-minute key duplicates half of what it is
asked to merge.  Two minutes absorbs that without swallowing a legitimate
re-work, since contest rules forbid a second contact on the same band and
mode.  Times compare as instants so midnight matches, and modes are
normalised or an imported SSB would miss our USB.

That normalisation is now written down: a rig mode is not an ADIF mode.  DIG
is the one the rig cannot answer — a rig in DIG is in FT8 or FT4 depending on
what is decoding — and WSPR never opens an entry at all, because hearing a
beacon is not a contact.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
[docs](trx-rs): write down how a contest and a confirmation are worked
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 5s
CI / test (push) Successful in 8m0s
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m57s
CI / lint (push) Successful in 2m32s
CI / frontend (push) Successful in 4m13s
CI / reuse (push) Successful in 6s
d539ff96e5
The manual gains the contest exchange, the Cabrillo entry and the
confirmations.  The plan marks all five phases done, corrects the API paths
to the /api/logbook they were built under -- /logbook is the page, and the
bookmarks API already shadows its own page that way -- and records the two
decisions phase 5 settled: that the Cabrillo header comes from the operator
because no log can derive it, and that a confirmation counts from whichever
bureau answered.

The plan itself travels with this branch: it was written on a branch of its
own that was never proposed for merge, and the earlier attempts to update it
from the implementation branches were silent no-ops, because the edits did
not assert that they had found what they were replacing.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
sjg merged commit d539ff96e5 into main 2026-08-07 21:50:02 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sjg/trx-rs#60