[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
@@ -633,9 +633,12 @@ function elementById(id) {
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
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" }
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 = { broadcast: false, digital: false };
const layoutCapabilities = { broadcast: false, digital: false, ham: false };
let activeRigId = null;
const layoutSections = [
{ id: "advanced-radio-controls", key: "advanced" },
@@ -1530,6 +1533,7 @@ function decodeCbor(buffer) {
var TAB_ORDER = [
"main",
"bookmarks",
"logbook",
"digital-modes",
"map",
"satellites",
@@ -1541,6 +1545,7 @@ var TAB_ORDER = [
var TAB_PATHS = {
main: "/",
bookmarks: "/bookmarks",
logbook: "/logbook",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
@@ -1764,6 +1769,7 @@ var pluginGroups = {
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
logbook: ["/logbook.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"]
};
@@ -1788,7 +1794,7 @@ async function loadPlugins(group) {
for (const path of pluginGroups[group]) await loadPlugin(path);
}
async function loadEagerPlugins() {
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
}
async function loadPluginsForTab(tab) {
await loadPlugins(tab);
@@ -2950,7 +2956,10 @@ 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)))
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)
});
}
function showHint(msg, duration) {
@@ -4,7 +4,7 @@ import {
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
} from "./chunk-REYSUJQ4.js";
import {
forActiveRig,
isActiveRigDecode
@@ -139,6 +139,9 @@ function renderAprsRow(pkt, isFresh) {
},
onCopy: (text) => {
void copyAprsCoords(text);
},
onLog: (call, gridsquare) => {
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" });
}
});
}
@@ -214,6 +214,16 @@ function summarizeAprsPayload(packet) {
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
function maidenheadForLatLon(lat, lon) {
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();
}
function renderAprsPacketRow(packet, options = {}) {
const row = document.createElement("details");
row.className = "aprs-packet";
@@ -224,7 +234,7 @@ function renderAprsPacketRow(packet, options = {}) {
const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (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></div></div>`;
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (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>`;
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
@@ -233,6 +243,15 @@ function renderAprsPacketRow(packet, options = {}) {
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
});
});
const logButton = row.querySelector("[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("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
@@ -152,6 +152,24 @@ function initializeFtxDecoder(config) {
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>`;
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 = () => {
@@ -1,6 +1,6 @@
import {
initializeFtxDecoder
} from "./chunk-PJ5Q7CWJ.js";
} from "./chunk-WU5EWJX4.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
@@ -1,6 +1,6 @@
import {
initializeFtxDecoder
} from "./chunk-PJ5Q7CWJ.js";
} from "./chunk-WU5EWJX4.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
@@ -2,7 +2,7 @@ import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers
} from "./chunk-PJ5Q7CWJ.js";
} from "./chunk-WU5EWJX4.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
@@ -4,7 +4,7 @@ import {
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
} from "./chunk-REYSUJQ4.js";
import {
forActiveRig,
isActiveRigDecode
@@ -0,0 +1,331 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/logbook.ts
var bridge = window;
var el = (id) => document.getElementById(id);
var form = el("log-entry-form");
var callInput = el("log-call");
var freqInput = el("log-freq");
var modeInput = el("log-mode");
var rstSentInput = el("log-rst-sent");
var rstRcvdInput = el("log-rst-rcvd");
var gridInput = el("log-grid");
var nameInput = el("log-name");
var commentInput = el("log-comment");
var operatorInput = el("log-operator");
var rowsBody = el("log-rows");
var summaryEl = el("log-summary");
var workedEl = el("log-worked-before");
var clockEl = el("log-clock");
var stationCallEl = el("log-station-callsign");
var stationRigEl = el("log-station-rig-name");
var stationGridEl = el("log-station-grid");
var filterCall = el("log-filter-call");
var filterBand = el("log-filter-band");
var filterMode = el("log-filter-mode");
var importBtn = el("log-import-btn");
var importFile = el("log-import-file");
var exportLink = el("log-export-btn");
var clearBtn = el("log-clear-btn");
var saveBtn = el("log-save-btn");
var entryStartedAt = null;
var entryRigId = null;
var entryRigName = null;
var entryGrid = null;
var qsos = [];
var workedRequest = 0;
function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
else hostCore.showHint(message, 2e3);
}
async function getJson(path) {
const response = await fetch(path);
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
return await response.json();
}
function formatFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "";
return String(Number((hz / 1e6).toFixed(6)));
}
function parseFreq(text) {
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
if (!Number.isFinite(value) || value <= 0) return null;
return value < 1e5 ? Math.round(value * 1e6) : Math.round(value);
}
function utcDate(iso) {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
}
function utcTime(iso) {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
}
async function openEntry(seed = {}) {
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(`/api/logbook/prefill?${params.toString()}`);
applyPrefill(prefill);
} catch (error) {
console.error("logbook prefill failed", error);
}
}
function applyPrefill(prefill) {
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();
}
function showClock(prefill) {
if (!clockEl) return;
const time = utcTime(prefill.started_at);
const drift = Math.abs(Date.now() - prefill.epoch_ms);
clockEl.textContent = drift > 1e3 ? `${time}Z · your clock is ${(drift / 1e3).toFixed(0)}s out` : `${time}Z`;
clockEl.classList.toggle("is-adrift", drift > 1e3);
}
function resetEntry() {
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.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();
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(() => ({}));
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
notify(`${call} logged`);
resetEntry();
await refreshLog();
} catch (error) {
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
function updateWorkedBefore() {
if (!workedEl) return;
const call = callInput?.value.trim() ?? "";
if (call.length < 3) {
workedEl.textContent = "";
return;
}
const request = ++workedRequest;
void getJson(
`/api/logbook/worked/${encodeURIComponent(call)}`
).then((answer) => {
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(() => {
});
}
function currentQuery() {
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() {
try {
const query = currentQuery();
const answer = await getJson(
`/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) {
console.error("logbook read failed", error);
}
}
function renderRows() {
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 = [
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);
}
function renderFilterOptions() {
for (const [select, values] of [
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b) => !!b))]],
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]]
]) {
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) {
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) {
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function importAdif(file) {
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();
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) {
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
function renderStation() {
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;
}
}
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 = "";
});
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
renderStation();
void openEntry();
void refreshLog();
@@ -1,6 +1,6 @@
import {
aprsSymbolSprite
} from "./chunk-OPEIVJGD.js";
} from "./chunk-REYSUJQ4.js";
// src/map-core.ts
function mapEl(id) {
@@ -22,6 +22,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-logbook" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 2h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M5.5 2v12"/><path d="M7.5 5.5h3M7.5 8h3M7.5 10.5h2"/></symbol>
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
@@ -53,6 +54,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
<span class="tab-label">Bookmarks</span>
</button>
<button class="tab" data-tab="logbook">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-logbook"/></svg>
<span class="tab-label">Logbook</span>
</button>
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
<span class="tab-label">Digital modes</span>
@@ -480,6 +485,111 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<div id="tab-logbook" class="tab-panel" style="display:none;">
<div id="logbook-panel" class="log-panel">
<!-- Station: who the contacts are logged under, and from where. Shown
once rather than typed into every entry. -->
<div class="log-station">
<div class="log-station-identity">
<span class="log-station-call" id="log-station-callsign">--</span>
<label class="log-station-operator">
<span>Operator</span>
<input type="text" id="log-operator" class="status-input" autocomplete="off"
spellcheck="false" aria-label="Operator callsign" />
</label>
</div>
<div class="log-station-rig">
<span id="log-station-rig-name">--</span>
<span id="log-station-grid" class="log-station-grid"></span>
<span id="log-clock" class="log-clock" aria-live="polite"></span>
</div>
</div>
<!-- Entry: opens pre-filled with the six fields the radio and the clock
can answer for. Everything else is the operator's. -->
<form id="log-entry-form" class="log-entry" autocomplete="off">
<div class="log-entry-grid">
<label class="log-field log-field-call">
<span>Callsign</span>
<input type="text" id="log-call" class="status-input" required
spellcheck="false" aria-describedby="log-worked-before" />
</label>
<label class="log-field">
<span>Frequency</span>
<input type="text" id="log-freq" class="status-input" inputmode="decimal" />
</label>
<label class="log-field log-field-narrow">
<span>Mode</span>
<input type="text" id="log-mode" class="status-input" spellcheck="false" />
</label>
<label class="log-field log-field-narrow">
<span>RST sent</span>
<input type="text" id="log-rst-sent" class="status-input" spellcheck="false" />
</label>
<label class="log-field log-field-narrow">
<span>RST rcvd</span>
<input type="text" id="log-rst-rcvd" class="status-input" spellcheck="false" />
</label>
<label class="log-field log-field-narrow">
<span>Locator</span>
<input type="text" id="log-grid" class="status-input" spellcheck="false" />
</label>
<label class="log-field">
<span>Name</span>
<input type="text" id="log-name" class="status-input" />
</label>
<label class="log-field">
<span>Comment</span>
<input type="text" id="log-comment" class="status-input" />
</label>
</div>
<div class="log-entry-actions">
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
<span class="log-entry-buttons">
<button type="button" id="log-clear-btn" class="log-secondary-btn">Clear</button>
<button type="submit" id="log-save-btn" class="log-primary-btn">Log contact</button>
</span>
</div>
</form>
<!-- The log itself. -->
<div class="log-list-toolbar">
<input type="text" id="log-filter-call" class="status-input" placeholder="Callsign&hellip;"
aria-label="Filter by callsign" />
<select id="log-filter-band" class="status-input" aria-label="Filter by band">
<option value="">All bands</option>
</select>
<select id="log-filter-mode" class="status-input" aria-label="Filter by mode">
<option value="">All modes</option>
</select>
<span class="log-toolbar-spacer"></span>
<a id="log-export-btn" class="log-secondary-btn" href="/api/logbook/export.adi"
download>Export ADIF</a>
<button type="button" id="log-import-btn" class="log-secondary-btn">Import ADIF</button>
<input type="file" id="log-import-file" accept=".adi,.adif,text/plain" hidden />
</div>
<div class="log-table-wrap">
<table class="log-table">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Time</th>
<th scope="col">Callsign</th>
<th scope="col">Band</th>
<th scope="col">Mode</th>
<th scope="col">Sent</th>
<th scope="col">Rcvd</th>
<th scope="col">Locator</th>
<th scope="col">Rig</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="log-rows"></tbody>
</table>
</div>
<div id="log-summary" class="log-summary" aria-live="polite"></div>
</div>
</div>
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
<div class="bm-toolbar">
<select id="bm-scope-picker" class="status-input" aria-label="Bookmark scope">
@@ -6388,3 +6388,248 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
}
#tab-statistics > .section-heading { margin-bottom: 0; }
/* =========================================================================
Logbook
========================================================================= */
.log-panel {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
/* Who the contacts are logged under, and from where: shown once, not typed
into every entry. */
.log-station {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0.85rem;
border: 1px solid var(--border-light);
border-radius: 0.5rem;
background: var(--bg-secondary);
}
.log-station-identity {
display: flex;
align-items: center;
gap: 0.85rem;
min-width: 0;
}
.log-station-call {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
}
.log-station-operator {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--text-muted);
}
.log-station-operator input {
width: 7rem;
text-transform: uppercase;
}
.log-station-rig {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.82rem;
color: var(--text-muted);
min-width: 0;
}
.log-station-grid {
font-variant-numeric: tabular-nums;
}
.log-clock {
font-variant-numeric: tabular-nums;
padding: 0.1rem 0.45rem;
border-radius: 0.3rem;
background: color-mix(in srgb, var(--card-bg) 70%, transparent);
}
/* A browser clock that disagrees with the radio's is worth saying out loud:
the times in a log are the one thing that cannot be corrected later. */
.log-clock.is-adrift {
color: var(--accent-yellow, #b7791f);
font-weight: 600;
}
.log-entry {
border: 1px solid var(--border-light);
border-radius: 0.5rem;
padding: 0.85rem;
background: var(--bg-secondary);
}
.log-entry-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
gap: 0.6rem 0.75rem;
}
.log-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
font-size: 0.75rem;
color: var(--text-muted);
}
.log-field input {
min-width: 0;
}
.log-field-call {
grid-column: span 2;
}
.log-field-call input {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.log-field-narrow {
max-width: 10rem;
}
.log-entry-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.75rem;
}
.log-entry-buttons {
display: inline-flex;
gap: 0.5rem;
margin-left: auto;
}
.log-worked {
font-size: 0.8rem;
color: var(--text-muted);
}
.log-worked.is-worked {
color: var(--accent-green);
font-weight: 600;
}
.log-primary-btn {
background: var(--accent-green);
color: #fff;
border: none;
border-radius: 0.35rem;
padding: 0.4rem 1.1rem;
font-weight: 600;
cursor: pointer;
}
.log-secondary-btn {
display: inline-flex;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 0.35rem;
color: var(--text-muted);
padding: 0.35rem 0.75rem;
font-size: 0.82rem;
text-decoration: none;
cursor: pointer;
}
.log-secondary-btn:hover {
background: var(--card-bg);
color: var(--text);
}
.log-list-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.log-list-toolbar .status-input {
max-width: 11rem;
}
.log-toolbar-spacer {
flex: 1 1 auto;
}
.log-table-wrap {
overflow-x: auto;
border: 1px solid var(--border-light);
border-radius: 0.5rem;
}
.log-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.log-table th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 0.5rem 0.65rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-light);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
white-space: nowrap;
}
.log-table td {
padding: 0.4rem 0.65rem;
border-bottom: 1px solid color-mix(in srgb, var(--border-light) 60%, transparent);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.log-table tr:last-child td {
border-bottom: none;
}
.log-table tbody tr:hover {
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
}
.log-cell-call {
font-weight: 700;
letter-spacing: 0.04em;
font-variant-numeric: normal;
}
.log-row-btn {
background: transparent;
border: 1px solid var(--border-light);
border-radius: 0.3rem;
color: var(--text-muted);
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
.log-row-btn:hover {
color: var(--text);
background: var(--card-bg);
}
.log-empty {
padding: 1.5rem 0.75rem;
text-align: center;
color: var(--text-muted);
white-space: normal;
}
.log-summary {
color: var(--text-muted);
font-size: 0.8rem;
}
/* The ham layout works the bands: the broadcast furniture goes, and the entry
form is the thing the panel opens on. */
body[data-operator-layout="ham"] .controls-col-wfm { display: none !important; }
body[data-operator-layout="ham"] #rds-panel { display: none !important; }
@media (max-width: 700px) {
.log-station,
.log-entry-actions {
flex-direction: column;
align-items: stretch;
}
.log-entry-buttons {
margin-left: 0;
}
.log-field-call {
grid-column: span 2;
}
.log-list-toolbar .status-input {
max-width: none;
flex: 1 1 8rem;
}
}