[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
@@ -33,6 +33,7 @@ await build({
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
logbook: path.join(sourceDir, "plugins", "logbook.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
"map-core": path.join(sourceDir, "map-core.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"),
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -265,7 +265,7 @@ interface TrxUi {
confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>;
notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement;
setActiveRig(rigId: string | null): void;
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital", boolean>>): void;
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital" | "ham", boolean>>): void;
setButtonState(element: HTMLButtonElement | null, options?: { active?: boolean; activeLabel?: string; inactiveLabel?: string; busy?: boolean; disabled?: boolean }): void;
closeMobileOverlays?(restoreFocus?: boolean): void;
syncSelectedTab(container: HTMLElement | null, selected: Element): void;
@@ -1522,6 +1522,9 @@ function refreshOperatorLayoutCapabilities() {
window.trxUi?.setLayoutCapabilities({
broadcast: rigModes.some((modes) => modes.includes("WFM")),
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
// A logbook is for contacts made, so the layout built around it is offered
// where a rig can transmit.
ham: serverRigs.some((rig) => rig?.tx === true),
});
}
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
export const TAB_ORDER = [
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
"main", "bookmarks", "logbook", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
] as const;
export type TabName = typeof TAB_ORDER[number];
@@ -11,6 +11,7 @@ export type TabName = typeof TAB_ORDER[number];
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
main: "/",
bookmarks: "/bookmarks",
logbook: "/logbook",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
@@ -4,7 +4,7 @@
type PluginGroup =
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
| "bookmarks" | "recorder" | "settings";
| "bookmarks" | "logbook" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
@@ -20,6 +20,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
logbook: ["/logbook.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"],
};
@@ -47,7 +48,7 @@ async function loadPlugins(group: string): Promise<void> {
}
export async function loadEagerPlugins(): Promise<void> {
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
}
export async function loadPluginsForTab(tab: string): Promise<void> {
@@ -324,6 +324,21 @@ export interface AprsRowOptions {
onMap?: (lat: number, lon: number) => void;
/** Puts a frame's coordinates on the clipboard. */
onCopy?: (text: string, button: HTMLElement) => void;
/** Opens a log entry for the station that sent the frame. */
onLog?: (call: string, gridsquare: string | null) => void;
}
/** A position as a six-character locator, which is what a log wants. */
function maidenheadForLatLon(lat: number, lon: number): string {
const adjustedLon = lon + 180;
const adjustedLat = lat + 90;
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20))
+ String.fromCharCode(65 + Math.floor(adjustedLat / 10));
const square = String(Math.floor((adjustedLon % 20) / 2)) + String(Math.floor(adjustedLat % 10));
const subLon = ((adjustedLon % 2) * 60) / 5;
const subLat = ((adjustedLat % 1) * 60) / 2.5;
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
return `${field}${square}${sub}`.toUpperCase();
}
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
@@ -370,6 +385,7 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
(options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") +
`</div>` +
`</div>`;
@@ -381,6 +397,17 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
});
});
const logButton = row.querySelector<HTMLElement>("[data-aprs-log]");
if (logButton) {
logButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const grid = packet.lat != null && packet.lon != null
? maidenheadForLatLon(packet.lat, packet.lon)
: null;
options.onLog?.(packet.srcCall ?? "", grid);
});
}
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
@@ -23,6 +23,7 @@ interface AprsBridge {
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
getDecodeRigMeta?: () => unknown;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
updateAprsBar?: () => void;
clearAprsBar?: () => void;
closeAprsBar?: () => void;
@@ -164,6 +165,9 @@ function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text) => { void copyAprsCoords(text); },
onLog: (call, gridsquare) => {
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? undefined, decoder: "aprs" });
},
});
}
@@ -49,6 +49,8 @@ interface FtxBridge {
updateFt8Bar?: () => void;
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
/** Opens a log entry from a decode. Pre-fills it; never logs by itself. */
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
trxUi: ConfirmApi;
clearFt8Bar?: () => void;
closeFt8Bar?: () => void;
@@ -224,6 +226,26 @@ export function initializeFtxDecoder(config: FtxConfig): void {
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
// A decode is not a contact: this opens an entry with what was heard in it
// and leaves the logging to the operator.
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
if (station) {
const log = document.createElement("button");
log.type = "button";
log.className = "ft8-log-btn";
log.textContent = "Log";
log.title = `Start a log entry for ${station}`;
log.addEventListener("click", (event) => {
event.stopPropagation();
const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
bridge.logContact?.({
call: station,
gridsquare: details.find((detail) => detail.station === station)?.grid,
decoder: id,
});
});
row.appendChild(log);
}
return row;
};
const render = (): void => {
@@ -22,6 +22,8 @@ export interface HostDecoderDescriptor {
export interface HostState {
readonly serverLat: number | null;
readonly serverLon: number | null;
/** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null;
readonly authEnabled: boolean;
readonly authRole: string | null;
readonly lastActiveRigId: string | null;
@@ -43,6 +45,7 @@ export interface HostCore {
armOptimisticFrequency(frequencyHz: number): void;
escapeMapHtml(value: string): string;
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
latLonToMaidenhead(lat: number, lon: number): string;
showHint(message: string, durationMs?: number): void;
formatFreqForStep(frequencyHz: number, stepHz: number): string;
refreshFreqDisplay(): void;
@@ -0,0 +1,430 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The station log: one panel that opens an entry, keeps the log, and takes
// ADIF in and out.
//
// Times come from the server, never from here. The browser may be a phone in
// another timezone with a clock nobody has checked, and a log is only worth
// keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js";
export {};
interface Qso {
id: string;
started_at: string;
call: string;
freq_hz: number;
band?: string | null;
mode: string;
submode?: string | null;
rst_sent?: string | null;
rst_rcvd?: string | null;
gridsquare?: string | null;
name?: string | null;
qth?: string | null;
comment?: string | null;
station_callsign?: string | null;
operator?: string | null;
my_gridsquare?: string | null;
my_rig?: string | null;
rig_id?: string | null;
}
interface Prefill {
started_at: string;
epoch_ms: number;
freq_hz: number;
band: string | null;
mode: string;
submode: string | null;
rig_id: string | null;
my_rig: string | null;
call: string | null;
gridsquare: string | null;
}
interface LogbookBridge {
trxUi: {
confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean>;
notify?(message: string, options?: { kind?: string }): void;
};
navigateToTab?: (name: string) => void;
/** Opens the panel with an entry started from a decode. */
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
}
const bridge = window as unknown as LogbookBridge;
const el = <T extends HTMLElement>(id: string): T | null => document.getElementById(id) as T | null;
const form = el<HTMLFormElement>("log-entry-form");
const callInput = el<HTMLInputElement>("log-call");
const freqInput = el<HTMLInputElement>("log-freq");
const modeInput = el<HTMLInputElement>("log-mode");
const rstSentInput = el<HTMLInputElement>("log-rst-sent");
const rstRcvdInput = el<HTMLInputElement>("log-rst-rcvd");
const gridInput = el<HTMLInputElement>("log-grid");
const nameInput = el<HTMLInputElement>("log-name");
const commentInput = el<HTMLInputElement>("log-comment");
const operatorInput = el<HTMLInputElement>("log-operator");
const rowsBody = el<HTMLTableSectionElement>("log-rows");
const summaryEl = el("log-summary");
const workedEl = el("log-worked-before");
const clockEl = el("log-clock");
const stationCallEl = el("log-station-callsign");
const stationRigEl = el("log-station-rig-name");
const stationGridEl = el("log-station-grid");
const filterCall = el<HTMLInputElement>("log-filter-call");
const filterBand = el<HTMLSelectElement>("log-filter-band");
const filterMode = el<HTMLSelectElement>("log-filter-mode");
const importBtn = el<HTMLButtonElement>("log-import-btn");
const importFile = el<HTMLInputElement>("log-import-file");
const exportLink = el<HTMLAnchorElement>("log-export-btn");
const clearBtn = el<HTMLButtonElement>("log-clear-btn");
const saveBtn = el<HTMLButtonElement>("log-save-btn");
/** The time the open entry was started, as the server gave it. */
let entryStartedAt: string | null = null;
/** The rig the open entry belongs to, likewise. */
let entryRigId: string | null = null;
let entryRigName: string | null = null;
let entryGrid: string | null = null;
let qsos: Qso[] = [];
let workedRequest = 0;
function notify(message: string, kind?: string): void {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
else hostCore.showHint(message, 2000);
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
return await response.json() as T;
}
function formatFreq(hz: number): string {
if (!Number.isFinite(hz) || hz <= 0) return "";
// Through Number, not a trailing-zero trim: trimming "20.000000" as a string
// walks back through the point and leaves "2".
return String(Number((hz / 1e6).toFixed(6)));
}
/** What the operator typed, in Hz: MHz unless it is plainly already Hz. */
function parseFreq(text: string): number | null {
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
if (!Number.isFinite(value) || value <= 0) return null;
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
}
function utcDate(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
}
function utcTime(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
}
// ── The entry ───────────────────────────────────────────────────────────────
/** Open an entry, pre-filled with the six fields the radio and clock answer. */
async function openEntry(
seed: { call?: string; gridsquare?: string | undefined; decoder?: string } = {},
): Promise<void> {
const params = new URLSearchParams();
if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId);
if (seed.decoder) params.set("decoder", seed.decoder);
if (seed.call) params.set("call", seed.call);
if (seed.gridsquare) params.set("gridsquare", seed.gridsquare);
try {
const prefill = await getJson<Prefill>(`/api/logbook/prefill?${params.toString()}`);
applyPrefill(prefill);
} catch (error: unknown) {
console.error("logbook prefill failed", error);
}
}
function applyPrefill(prefill: Prefill): void {
entryStartedAt = prefill.started_at;
entryRigId = prefill.rig_id;
entryRigName = prefill.my_rig;
if (freqInput) freqInput.value = formatFreq(prefill.freq_hz);
if (modeInput) modeInput.value = prefill.submode ?? prefill.mode;
if (callInput && prefill.call) callInput.value = prefill.call;
if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare;
if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig";
showClock(prefill);
updateWorkedBefore();
}
/** The entry's time, and whether this browser agrees with it. */
function showClock(prefill: Prefill): void {
if (!clockEl) return;
const time = utcTime(prefill.started_at);
const drift = Math.abs(Date.now() - prefill.epoch_ms);
clockEl.textContent = drift > 1000
? `${time}Z · your clock is ${(drift / 1000).toFixed(0)}s out`
: `${time}Z`;
clockEl.classList.toggle("is-adrift", drift > 1000);
}
/** Clear the entry and open a fresh one. */
function resetEntry(): void {
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) {
if (input) input.value = "";
}
if (workedEl) workedEl.textContent = "";
void openEntry();
}
async function submitEntry(event: Event): Promise<void> {
event.preventDefault();
const call = callInput?.value.trim() ?? "";
if (!call) {
notify("A contact needs a callsign", "error");
callInput?.focus();
return;
}
const freqHz = parseFreq(freqInput?.value ?? "");
if (freqHz == null) {
notify("A contact needs a frequency", "error");
freqInput?.focus();
return;
}
const typedMode = (modeInput?.value ?? "").trim().toUpperCase();
// USB and LSB are how a rig says SSB; the log wants the mode with the
// sideband beside it, which is what other loggers read.
const isSideband = typedMode === "USB" || typedMode === "LSB";
const body = {
// The server stamps the time; this is the one it gave when the entry
// opened, so a contact logged five minutes later keeps the time it started.
started_at: entryStartedAt,
call,
freq_hz: freqHz,
mode: isSideband ? "SSB" : typedMode,
submode: isSideband ? typedMode : null,
rst_sent: rstSentInput?.value ?? null,
rst_rcvd: rstRcvdInput?.value ?? null,
gridsquare: gridInput?.value ?? null,
name: nameInput?.value ?? null,
comment: commentInput?.value ?? null,
station_callsign: stationCallEl?.textContent?.trim() ?? null,
operator: operatorInput?.value ?? null,
my_gridsquare: entryGrid,
my_rig: entryRigName,
rig_id: entryRigId,
};
if (saveBtn) saveBtn.disabled = true;
try {
const response = await fetch("/api/logbook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const detail = await response.json().catch(() => ({})) as { error?: string };
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
notify(`${call} logged`);
resetEntry();
await refreshLog();
} catch (error: unknown) {
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
/** Whether this station has been worked, and on what. */
function updateWorkedBefore(): void {
if (!workedEl) return;
const call = callInput?.value.trim() ?? "";
if (call.length < 3) {
workedEl.textContent = "";
return;
}
const request = ++workedRequest;
void getJson<{ worked: { band: string; mode: string }[] }>(
`/api/logbook/worked/${encodeURIComponent(call)}`,
).then((answer) => {
// A slower answer to an earlier callsign must not overwrite a later one.
if (request !== workedRequest || !workedEl) return;
if (answer.worked.length === 0) {
workedEl.textContent = "Not worked before";
workedEl.classList.remove("is-worked");
return;
}
const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", ");
workedEl.textContent = `Worked before: ${where}`;
workedEl.classList.add("is-worked");
}).catch(() => { /* the log will still take the contact */ });
}
// ── The log ─────────────────────────────────────────────────────────────────
function currentQuery(): string {
const params = new URLSearchParams();
const call = filterCall?.value.trim();
if (call) params.set("call", call);
if (filterBand?.value) params.set("band", filterBand.value);
if (filterMode?.value) params.set("mode", filterMode.value);
return params.toString();
}
async function refreshLog(): Promise<void> {
try {
const query = currentQuery();
const answer = await getJson<{ total: number; qsos: Qso[] }>(
`/api/logbook${query ? `?${query}` : ""}`,
);
qsos = answer.qsos;
renderRows();
renderFilterOptions();
if (summaryEl) {
summaryEl.textContent = query
? `${String(qsos.length)} of ${String(answer.total)} contacts`
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
}
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
} catch (error: unknown) {
console.error("logbook read failed", error);
}
}
function renderRows(): void {
if (!rowsBody) return;
if (qsos.length === 0) {
rowsBody.innerHTML =
'<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here,'
+ " or bring a log in with Import ADIF.</td></tr>";
return;
}
const fragment = document.createDocumentFragment();
for (const qso of qsos) {
const row = document.createElement("tr");
row.dataset.qsoId = qso.id;
const cells: (string | null | undefined)[] = [
utcDate(qso.started_at),
utcTime(qso.started_at),
qso.call,
qso.band ?? "",
qso.submode ?? qso.mode,
qso.rst_sent ?? "",
qso.rst_rcvd ?? "",
qso.gridsquare ?? "",
qso.my_rig ?? "",
];
for (const [index, value] of cells.entries()) {
const cell = document.createElement("td");
cell.textContent = value ?? "";
if (index === 2) cell.className = "log-cell-call";
row.appendChild(cell);
}
const actions = document.createElement("td");
const remove = document.createElement("button");
remove.type = "button";
remove.className = "log-row-btn";
remove.textContent = "Delete";
remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`);
remove.addEventListener("click", () => { void deleteQso(qso); });
actions.appendChild(remove);
row.appendChild(actions);
fragment.appendChild(row);
}
rowsBody.replaceChildren(fragment);
}
/** Bands and modes the log actually holds, so the filters offer only those. */
function renderFilterOptions(): void {
for (const [select, values] of [
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b): b is string => !!b))]],
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]],
] as const) {
if (!select) continue;
const chosen = select.value;
const known = new Set([...select.options].map((option) => option.value));
for (const value of [...values].sort()) {
if (known.has(value)) continue;
select.add(new Option(value, value));
}
select.value = chosen;
}
}
async function deleteQso(qso: Qso): Promise<void> {
const confirmed = await bridge.trxUi.confirm({
title: "Delete this contact?",
message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`,
confirmLabel: "Delete",
});
if (!confirmed) return;
try {
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" });
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
await refreshLog();
} catch (error: unknown) {
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function importAdif(file: File): Promise<void> {
try {
const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() });
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
const outcome = await response.json() as { added: number; duplicate: number; rejected: string[] };
const parts = [`${String(outcome.added)} added`];
if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`);
if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`);
notify(parts.join(", "));
await refreshLog();
} catch (error: unknown) {
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
// ── Station line ────────────────────────────────────────────────────────────
function renderStation(): void {
const callsign = hostState.ownerCallsign ?? "";
if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set";
if (operatorInput && !operatorInput.value) operatorInput.value = callsign;
if (stationGridEl) {
const grid = hostState.serverLat != null && hostState.serverLon != null
? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon)
: "";
entryGrid = grid || null;
stationGridEl.textContent = grid;
}
}
// ── Wiring ──────────────────────────────────────────────────────────────────
form?.addEventListener("submit", (event) => { void submitEntry(event); });
clearBtn?.addEventListener("click", resetEntry);
callInput?.addEventListener("input", updateWorkedBefore);
for (const control of [filterCall, filterBand, filterMode]) {
control?.addEventListener("input", () => { void refreshLog(); });
control?.addEventListener("change", () => { void refreshLog(); });
}
importBtn?.addEventListener("click", () => { importFile?.click(); });
importFile?.addEventListener("change", () => {
const file = importFile.files?.[0];
if (file) void importAdif(file);
importFile.value = "";
});
/** Start an entry from a decode, and show the operator where it went. */
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
renderStation();
void openEntry();
void refreshLog();
@@ -24,8 +24,8 @@ interface ButtonStateOptions {
disabled?: boolean;
}
type TabKind = "primary" | "secondary";
type LayoutCapability = "broadcast" | "digital";
type LayoutName = "compact" | "broadcast" | "digital" | "full";
type LayoutCapability = "broadcast" | "digital" | "ham";
type LayoutName = "compact" | "broadcast" | "digital" | "full" | "ham";
interface OperatorLayout {
label: string;
unavailable?: string;
@@ -191,8 +191,11 @@ function elementById<T extends HTMLElement>(id: string): T {
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
// Working the bands: the log is the task and the radio is the instrument,
// so this one opens on the logbook with the controls a keystroke away.
ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" },
};
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false };
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false, ham: false };
let activeRigId: string | null = null;
// A layout seeds the collapsible sections; it does not hold them there.
@@ -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");
@@ -140,6 +140,7 @@ export async function startWebFixture({
history = {},
bookmarks = [],
backgroundDecode = null,
logbook = [],
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
@@ -279,6 +280,83 @@ export async function startWebFixture({
response.writeHead(200).end();
return;
}
// The logbook: an in-memory station log, enough for the panel to open an
// entry, write it, read it back and export it.
if (url.pathname.startsWith("/api/logbook")) {
const tail = url.pathname.slice("/api/logbook".length);
if (tail === "/prefill") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
started_at: new Date().toISOString(),
epoch_ms: Date.now(),
freq_hz: status.status.freq.hz,
band: "20m",
mode: url.searchParams.get("decoder") === "ft8" ? "FT8" : "SSB",
submode: url.searchParams.get("decoder") === "ft8" ? null : "USB",
rig_id: url.searchParams.get("remote") ?? "rig-a",
my_rig: "Primary fixture",
call: url.searchParams.get("call"),
gridsquare: url.searchParams.get("gridsquare"),
}));
return;
}
if (tail.startsWith("/worked/")) {
const call = decodeURIComponent(tail.slice("/worked/".length)).toUpperCase();
const worked = logbook
.filter((qso) => qso.call === call)
.map((qso) => ({ band: qso.band, mode: qso.mode }));
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ call, worked }));
return;
}
if (tail === "/export.adi") {
const body = logbook
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
.join("");
response.writeHead(200, { "content-type": "text/plain" });
response.end(body);
return;
}
if (request.method === "POST" && tail === "") {
const raw = await new Promise((resolve) => {
let text = "";
request.on("data", (chunk) => { text += chunk; });
request.on("end", () => resolve(text));
});
const input = JSON.parse(raw);
const qso = {
id: `qso-${String(logbook.length + 1)}`,
started_at: input.started_at ?? new Date().toISOString(),
call: String(input.call).toUpperCase(),
freq_hz: input.freq_hz,
band: "20m",
mode: input.mode,
submode: input.submode,
rst_sent: input.rst_sent,
rst_rcvd: input.rst_rcvd,
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
my_rig: input.my_rig,
operator: input.operator,
};
logbook.unshift(qso);
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(qso));
return;
}
if (request.method === "DELETE") {
const id = tail.replace("/", "");
const index = logbook.findIndex((qso) => qso.id === id);
if (index >= 0) logbook.splice(index, 1);
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ deleted: index >= 0 }));
return;
}
const wanted = (url.searchParams.get("call") ?? "").toUpperCase();
const matching = wanted ? logbook.filter((qso) => qso.call.includes(wanted)) : logbook;
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ total: logbook.length, qsos: matching }));
return;
}
// Background decode: the panel reads its config, its status, and the
// bookmark list, and writes the config back.
if (url.pathname.startsWith("/background-decode/")) {