[feat](trx-rs): keep a station log, and a layout to work the bands from
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 9m12s
CI / frontend (pull_request) Successful in 5m52s
CI / reuse (pull_request) Successful in 6s

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>
This commit is contained in:
sjg
2026-08-07 21:12:03 +02:00
co-authored by Claude Opus 5
parent a19633e81f
commit 18b2d0efe6
45 changed files with 3843 additions and 20 deletions
@@ -0,0 +1,162 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The station log: the panel opens an entry pre-filled from the radio and the
// server's clock, writes a contact, keeps it, and says whether the station has
// been worked before. And the ham layout, which is offered only where a rig can
// transmit -- a log is of contacts made.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, window, location */
const HELD = [
{
id: "q1", started_at: "2026-08-06T14:22:00Z", call: "DL3ABC", freq_hz: 14_074_000,
band: "20m", mode: "FT8", rst_sent: "-07", rst_rcvd: "-12", gridsquare: "JO31",
my_rig: "Primary fixture",
},
{
id: "q2", started_at: "2026-08-06T13:05:00Z", call: "SP9XYZ", freq_hz: 7_120_000,
band: "40m", mode: "SSB", submode: "LSB", rst_sent: "59", rst_rcvd: "57",
gridsquare: "JO90", my_rig: "Primary fixture",
},
];
const fixture = await startWebFixture({ spectrum: true, tx: true, logbook: [...HELD] });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
const readPanel = () => page.evaluate(() => ({
rows: [...(document.getElementById("log-rows")?.children ?? [])].map((row) => ({
call: row.querySelector(".log-cell-call")?.textContent ?? "",
cells: [...row.querySelectorAll("td")].map((cell) => cell.textContent),
})),
summary: document.getElementById("log-summary")?.textContent ?? "",
freq: document.getElementById("log-freq")?.value ?? "",
mode: document.getElementById("log-mode")?.value ?? "",
call: document.getElementById("log-call")?.value ?? "",
grid: document.getElementById("log-grid")?.value ?? "",
rig: document.getElementById("log-station-rig-name")?.textContent ?? "",
clock: document.getElementById("log-clock")?.textContent ?? "",
worked: document.getElementById("log-worked-before")?.textContent ?? "",
}));
try {
await page.setViewportSize({ width: 1400, height: 950 });
await page.goto(`${fixture.origin}/logbook`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-logbook").waitFor({ state: "visible" });
await page.waitForTimeout(2500);
// The log is there, newest first, with the band and mode of each contact.
const opened = await readPanel();
assert.deepEqual(opened.rows.map((row) => row.call), ["DL3ABC", "SP9XYZ"]);
assert.equal(opened.summary, "2 contacts");
// A phone contact shows the sideband it was worked on, not just "SSB".
assert.ok(opened.rows[1].cells.includes("LSB"), `the row reads ${JSON.stringify(opened.rows[1].cells)}`);
// The entry opened pre-filled: frequency and mode from the rig, the rig's
// name, and the time from the server.
assert.equal(opened.freq, "100", `the frequency pre-filled as "${opened.freq}"`);
assert.equal(opened.mode, "USB", `the mode pre-filled as "${opened.mode}"`);
assert.equal(opened.rig, "Primary fixture");
assert.match(opened.clock, /^\d{2}:\d{2}:\d{2}Z$/, `the clock reads "${opened.clock}"`);
// ...and nothing else was: a report is the operator's to give.
assert.equal(opened.call, "");
assert.equal(await page.evaluate(() => document.getElementById("log-rst-sent")?.value), "");
// Worked-before answers as the callsign is typed.
await page.locator("#log-call").fill("DL3ABC");
await page.waitForTimeout(600);
assert.match((await readPanel()).worked, /Worked before: 20m FT8/);
await page.locator("#log-call").fill("OZ1NEW");
await page.waitForTimeout(600);
assert.match((await readPanel()).worked, /Not worked before/);
// Logging writes the contact and clears the entry for the next one.
await page.locator("#log-rst-sent").fill("59");
await page.locator("#log-rst-rcvd").fill("57");
await page.locator("#log-save-btn").click();
await page.waitForTimeout(900);
const afterLogging = await readPanel();
assert.deepEqual(afterLogging.rows.map((row) => row.call), ["OZ1NEW", "DL3ABC", "SP9XYZ"]);
assert.equal(afterLogging.summary, "3 contacts");
assert.equal(afterLogging.call, "", "the entry kept the callsign after logging it");
assert.equal(afterLogging.freq, "100", "the entry did not re-open pre-filled");
// A decode row starts an entry rather than logging one: what was heard goes
// into the form, and the operator finishes it.
await page.evaluate(() => { window.navigateToTab("digital-modes"); });
await page.waitForTimeout(800);
await page.locator('#tab-digital-modes .sub-tab[data-subtab="ft8"]').click();
await page.waitForTimeout(400);
await page.evaluate(() => {
window.trxPluginRuntime.dispatch("ft8", {
message: "CQ SP2SJG JO94", snr_db: -7, dt_s: 0.2, freq_hz: 1200,
ts_ms: Date.now(), rig_id: "rig-a",
});
});
await page.waitForTimeout(600);
await page.locator("#ft8-messages .ft8-log-btn").first().click();
await page.waitForTimeout(900);
const fromDecode = await page.evaluate(() => ({
path: location.pathname,
call: document.getElementById("log-call")?.value ?? "",
grid: document.getElementById("log-grid")?.value ?? "",
mode: document.getElementById("log-mode")?.value ?? "",
rows: document.getElementById("log-rows")?.children.length ?? 0,
}));
assert.equal(fromDecode.path, "/logbook", `the Log button landed on ${fromDecode.path}`);
assert.equal(fromDecode.call, "SP2SJG", "the heard callsign did not reach the entry");
assert.equal(fromDecode.grid, "JO94", "the heard locator did not reach the entry");
// The decoder answers for the mode, because a rig in DIG cannot.
assert.equal(fromDecode.mode, "FT8", `the mode came through as "${fromDecode.mode}"`);
assert.equal(fromDecode.rows, 3, "a decode logged itself instead of opening an entry");
// The ham layout is on offer, because this rig can transmit.
const layouts = await page.evaluate(() =>
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
assert.ok(layouts.includes("ham"), `the layouts are ${JSON.stringify(layouts)}`);
// Choosing it the way the picker does. The <select> itself measures zero
// wide in a headless window, so Playwright will not click it; the change
// event is what the picker is wired to either way.
await page.evaluate(() => { window.navigateToTab("main"); });
await page.waitForTimeout(600);
await page.evaluate(() => {
const select = document.getElementById("operator-layout-select");
select.value = "ham";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
await page.waitForTimeout(900);
const inHamLayout = await page.evaluate(() => ({
layout: document.body.dataset.operatorLayout,
path: location.pathname,
}));
assert.equal(inHamLayout.layout, "ham");
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// Without a transmitter there is nothing to log, so the layout is not offered.
const listenerFixture = await startWebFixture({ spectrum: true, tx: false });
const listener = await startBrowser(chromium);
try {
await listener.page.setViewportSize({ width: 1400, height: 950 });
await listener.page.goto(`${listenerFixture.origin}/`, { waitUntil: "domcontentloaded" });
await listener.page.locator("#content").waitFor({ state: "visible" });
await listener.page.waitForTimeout(2000);
const layouts = await listener.page.evaluate(() =>
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
assert.ok(!layouts.includes("ham"), `a receiver was offered the ham layout: ${JSON.stringify(layouts)}`);
} finally {
await listener.browser.close();
await listenerFixture.close();
}
console.log("logbook tests passed");