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

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>
This commit is contained in:
sjg
2026-08-07 21:28:40 +02:00
co-authored by Claude Opus 5
parent 18b2d0efe6
commit ee185b98bc
12 changed files with 1296 additions and 3 deletions
@@ -31,6 +31,16 @@ 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 contestIdInput = el("log-contest-id");
var stxInput = el("log-stx");
var srxInput = el("log-srx");
var statisticsRows = el("log-statistics-rows");
var cabrilloContest = el("log-cbr-contest");
var cabrilloCallsign = el("log-cbr-callsign");
var cabrilloOperator = el("log-cbr-operator");
var cabrilloPower = el("log-cbr-power");
var cabrilloScore = el("log-cbr-score");
var cabrilloExport = el("log-cbr-export");
var entryStartedAt = null;
var entryRigId = null;
var entryRigName = null;
@@ -55,6 +65,12 @@ function parseFreq(text) {
if (!Number.isFinite(value) || value <= 0) return null;
return value < 1e5 ? Math.round(value * 1e6) : Math.round(value);
}
function numberOrNull(text) {
const trimmed = (text ?? "").trim();
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
const value = Number(trimmed);
return Number.isSafeInteger(value) ? value : null;
}
function utcDate(iso) {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
@@ -96,7 +112,7 @@ function showClock(prefill) {
clockEl.classList.toggle("is-adrift", drift > 1e3);
}
function resetEntry() {
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) {
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
if (input) input.value = "";
}
if (workedEl) workedEl.textContent = "";
@@ -131,6 +147,13 @@ async function submitEntry(event) {
gridsquare: gridInput?.value ?? null,
name: nameInput?.value ?? null,
comment: commentInput?.value ?? null,
contest_id: contestIdInput?.value ?? null,
// The exchange is a number when it is a serial and a word when it is a
// zone or a section; both are kept, and the log writes whichever it has.
stx: numberOrNull(stxInput?.value),
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
srx: numberOrNull(srxInput?.value),
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
station_callsign: stationCallEl?.textContent?.trim() ?? null,
operator: operatorInput?.value ?? null,
my_gridsquare: entryGrid,
@@ -149,7 +172,9 @@ async function submitEntry(event) {
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
notify(`${call} logged`);
const sent = numberOrNull(stxInput?.value);
resetEntry();
if (stxInput && sent != null) stxInput.value = String(sent + 1);
await refreshLog();
} catch (error) {
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -201,10 +226,51 @@ async function refreshLog() {
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}` : ""}`;
await refreshStatistics();
} catch (error) {
console.error("logbook read failed", error);
}
}
async function refreshStatistics() {
if (!statisticsRows) return;
try {
const answer = await getJson("/api/logbook/statistics");
if (answer.bands.length === 0) {
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
return;
}
const fragment = document.createDocumentFragment();
for (const band of answer.bands) {
const row = document.createElement("tr");
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
const cell = document.createElement("td");
cell.textContent = String(value);
row.appendChild(cell);
}
fragment.appendChild(row);
}
statisticsRows.replaceChildren(fragment);
} catch (error) {
console.error("logbook statistics failed", error);
}
}
function syncCabrilloLink() {
if (!cabrilloExport) return;
const params = new URLSearchParams();
const contest = cabrilloContest?.value.trim();
if (contest) {
params.set("contest", contest);
}
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
if (callsign) params.set("callsign", callsign);
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
const score = numberOrNull(cabrilloScore?.value);
if (score != null) params.set("claimed_score", String(score));
const operator = operatorInput?.value.trim();
if (operator) params.set("operators", operator);
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
}
function renderRows() {
if (!rowsBody) return;
if (qsos.length === 0) {
@@ -224,7 +290,8 @@ function renderRows() {
qso.rst_sent ?? "",
qso.rst_rcvd ?? "",
qso.gridsquare ?? "",
qso.my_rig ?? ""
qso.my_rig ?? "",
qso.confirmed ? "✓" : ""
];
for (const [index, value] of cells.entries()) {
const cell = document.createElement("td");
@@ -233,6 +300,15 @@ function renderRows() {
row.appendChild(cell);
}
const actions = document.createElement("td");
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "log-row-btn";
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
confirm.title = qso.confirmed ? "Mark this contact as not confirmed" : "Mark this contact confirmed by QSL";
confirm.addEventListener("click", () => {
void setConfirmed(qso, !qso.confirmed);
});
actions.appendChild(confirm);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "log-row-btn";
@@ -262,6 +338,24 @@ function renderFilterOptions() {
select.value = chosen;
}
}
async function setConfirmed(qso, confirmed) {
try {
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...qso,
// A card is a card: this is the paper one, and an electronic
// confirmation the log already holds is left where it is.
qsl_rcvd: confirmed ? "Y" : "N"
})
});
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
await refreshLog();
} catch (error) {
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function deleteQso(qso) {
const confirmed = await bridge.trxUi.confirm({
title: "Delete this contact?",
@@ -314,6 +408,10 @@ for (const control of [filterCall, filterBand, filterMode]) {
void refreshLog();
});
}
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
control?.addEventListener("input", syncCabrilloLink);
control?.addEventListener("change", syncCabrilloLink);
}
importBtn?.addEventListener("click", () => {
importFile?.click();
});
@@ -327,5 +425,9 @@ bridge.logContact = (seed) => {
void openEntry(seed).then(() => callInput?.focus());
};
renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
}
syncCabrilloLink();
void openEntry();
void refreshLog();