[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:
@@ -32,6 +32,20 @@ interface Qso {
|
||||
my_gridsquare?: string | null;
|
||||
my_rig?: string | null;
|
||||
rig_id?: string | null;
|
||||
contest_id?: string | null;
|
||||
stx?: number | null;
|
||||
srx?: number | null;
|
||||
srx_string?: string | null;
|
||||
qsl_rcvd?: string | null;
|
||||
lotw_qsl_rcvd?: string | null;
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
interface BandStatistics {
|
||||
band: string;
|
||||
contacts: number;
|
||||
stations: number;
|
||||
confirmed: number;
|
||||
}
|
||||
|
||||
interface Prefill {
|
||||
@@ -86,6 +100,16 @@ 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");
|
||||
const contestIdInput = el<HTMLInputElement>("log-contest-id");
|
||||
const stxInput = el<HTMLInputElement>("log-stx");
|
||||
const srxInput = el<HTMLInputElement>("log-srx");
|
||||
const statisticsRows = el<HTMLTableSectionElement>("log-statistics-rows");
|
||||
const cabrilloContest = el<HTMLInputElement>("log-cbr-contest");
|
||||
const cabrilloCallsign = el<HTMLInputElement>("log-cbr-callsign");
|
||||
const cabrilloOperator = el<HTMLSelectElement>("log-cbr-operator");
|
||||
const cabrilloPower = el<HTMLSelectElement>("log-cbr-power");
|
||||
const cabrilloScore = el<HTMLInputElement>("log-cbr-score");
|
||||
const cabrilloExport = el<HTMLAnchorElement>("log-cbr-export");
|
||||
|
||||
/** The time the open entry was started, as the server gave it. */
|
||||
let entryStartedAt: string | null = null;
|
||||
@@ -121,6 +145,14 @@ function parseFreq(text: string): number | null {
|
||||
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
|
||||
}
|
||||
|
||||
/** A serial, or null when the exchange is a word rather than a number. */
|
||||
function numberOrNull(text: string | undefined): number | null {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
|
||||
const value = Number(trimmed);
|
||||
return Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function utcDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
||||
@@ -176,7 +208,9 @@ function showClock(prefill: Prefill): void {
|
||||
|
||||
/** Clear the entry and open a fresh one. */
|
||||
function resetEntry(): void {
|
||||
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) {
|
||||
// The contest and the serial sent stay: they belong to the session, not to
|
||||
// the contact just logged.
|
||||
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
|
||||
if (input) input.value = "";
|
||||
}
|
||||
if (workedEl) workedEl.textContent = "";
|
||||
@@ -214,6 +248,13 @@ async function submitEntry(event: Event): Promise<void> {
|
||||
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,
|
||||
@@ -232,7 +273,11 @@ async function submitEntry(event: Event): Promise<void> {
|
||||
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
||||
}
|
||||
notify(`${call} logged`);
|
||||
// A contest runs on serials: the next one is this one plus one, so it is
|
||||
// not retyped forty times an hour.
|
||||
const sent = numberOrNull(stxInput?.value);
|
||||
resetEntry();
|
||||
if (stxInput && sent != null) stxInput.value = String(sent + 1);
|
||||
await refreshLog();
|
||||
} catch (error: unknown) {
|
||||
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
@@ -292,11 +337,57 @@ async function refreshLog(): Promise<void> {
|
||||
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
||||
await refreshStatistics();
|
||||
} catch (error: unknown) {
|
||||
console.error("logbook read failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** What has been worked and confirmed, band by band. */
|
||||
async function refreshStatistics(): Promise<void> {
|
||||
if (!statisticsRows) return;
|
||||
try {
|
||||
const answer = await getJson<{ bands: BandStatistics[] }>("/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: unknown) {
|
||||
console.error("logbook statistics failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** The Cabrillo link, carrying the header the operator filled in. */
|
||||
function syncCabrilloLink(): void {
|
||||
if (!cabrilloExport) return;
|
||||
const params = new URLSearchParams();
|
||||
const contest = cabrilloContest?.value.trim();
|
||||
if (contest) {
|
||||
// Only that contest's contacts belong in the entry.
|
||||
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(): void {
|
||||
if (!rowsBody) return;
|
||||
if (qsos.length === 0) {
|
||||
@@ -319,6 +410,7 @@ function renderRows(): void {
|
||||
qso.rst_rcvd ?? "",
|
||||
qso.gridsquare ?? "",
|
||||
qso.my_rig ?? "",
|
||||
qso.confirmed ? "✓" : "",
|
||||
];
|
||||
for (const [index, value] of cells.entries()) {
|
||||
const cell = document.createElement("td");
|
||||
@@ -327,6 +419,17 @@ function renderRows(): void {
|
||||
row.appendChild(cell);
|
||||
}
|
||||
const actions = document.createElement("td");
|
||||
// Confirming is the commonest edit a log gets, so it is a button rather
|
||||
// than a form: a card arrives, and the contact counts towards an award.
|
||||
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";
|
||||
@@ -357,6 +460,26 @@ function renderFilterOptions(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Record, or withdraw, the other station's confirmation. */
|
||||
async function setConfirmed(qso: Qso, confirmed: boolean): Promise<void> {
|
||||
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: unknown) {
|
||||
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteQso(qso: Qso): Promise<void> {
|
||||
const confirmed = await bridge.trxUi.confirm({
|
||||
title: "Delete this contact?",
|
||||
@@ -412,6 +535,10 @@ for (const control of [filterCall, filterBand, filterMode]) {
|
||||
control?.addEventListener("input", () => { void refreshLog(); });
|
||||
control?.addEventListener("change", () => { void refreshLog(); });
|
||||
}
|
||||
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
|
||||
control?.addEventListener("input", syncCabrilloLink);
|
||||
control?.addEventListener("change", syncCabrilloLink);
|
||||
}
|
||||
importBtn?.addEventListener("click", () => { importFile?.click(); });
|
||||
importFile?.addEventListener("change", () => {
|
||||
const file = importFile.files?.[0];
|
||||
@@ -426,5 +553,9 @@ bridge.logContact = (seed) => {
|
||||
};
|
||||
|
||||
renderStation();
|
||||
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||
}
|
||||
syncCabrilloLink();
|
||||
void openEntry();
|
||||
void refreshLog();
|
||||
|
||||
@@ -137,6 +137,69 @@ try {
|
||||
assert.equal(inHamLayout.layout, "ham");
|
||||
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
|
||||
|
||||
// ── Contest working ─────────────────────────────────────────────────────
|
||||
// The exchange belongs to the session: the contest and the serial sent stay
|
||||
// between contacts, and the serial counts on by itself.
|
||||
await page.evaluate(() => { window.navigateToTab("logbook"); });
|
||||
await page.waitForTimeout(600);
|
||||
await page.locator("#log-contest-block > summary").click();
|
||||
await page.locator("#log-contest-id").fill("CQ-WW-SSB");
|
||||
await page.locator("#log-stx").fill("1");
|
||||
await page.locator("#log-call").fill("DL9CON");
|
||||
await page.locator("#log-srx").fill("014");
|
||||
await page.locator("#log-rst-sent").fill("59");
|
||||
await page.locator("#log-rst-rcvd").fill("59");
|
||||
await page.locator("#log-save-btn").click();
|
||||
await page.waitForTimeout(900);
|
||||
const afterContest = await page.evaluate(() => ({
|
||||
contest: document.getElementById("log-contest-id")?.value ?? "",
|
||||
stx: document.getElementById("log-stx")?.value ?? "",
|
||||
srx: document.getElementById("log-srx")?.value ?? "",
|
||||
call: document.getElementById("log-call")?.value ?? "",
|
||||
}));
|
||||
assert.equal(afterContest.contest, "CQ-WW-SSB", "the contest was cleared with the contact");
|
||||
assert.equal(afterContest.stx, "2", `the serial sent went to "${afterContest.stx}"`);
|
||||
assert.equal(afterContest.srx, "", "the received exchange was kept for the next station");
|
||||
assert.equal(afterContest.call, "");
|
||||
|
||||
// The Cabrillo link carries the header, and names the contest so that only
|
||||
// its contacts are entered.
|
||||
await page.locator("#log-contest-export-block > summary").click();
|
||||
await page.locator("#log-cbr-contest").fill("CQ-WW-SSB");
|
||||
await page.locator("#log-cbr-score").fill("4242");
|
||||
await page.waitForTimeout(300);
|
||||
const cabrillo = await page.evaluate(() =>
|
||||
document.getElementById("log-cbr-export")?.getAttribute("href") ?? "");
|
||||
assert.match(cabrillo, /contest=CQ-WW-SSB/, `the Cabrillo link reads "${cabrillo}"`);
|
||||
assert.match(cabrillo, /claimed_score=4242/, `the Cabrillo link reads "${cabrillo}"`);
|
||||
const entry = await page.evaluate(async (href) => {
|
||||
const response = await fetch(href);
|
||||
return await response.text();
|
||||
}, cabrillo);
|
||||
assert.match(entry, /^START-OF-LOG: 3\.0/, entry);
|
||||
assert.match(entry, /DL9CON/, "the contest contact is not in the entry");
|
||||
assert.ok(!entry.includes("OZ1NEW"), "a contact outside the contest was entered");
|
||||
|
||||
// ── Confirmations ───────────────────────────────────────────────────────
|
||||
// A card arrives: the contact is marked confirmed, and the per-band report
|
||||
// counts it.
|
||||
const confirmRow = page.locator("#log-rows tr", { hasText: "DL9CON" }).first();
|
||||
await confirmRow.getByRole("button", { name: "Confirm" }).click();
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator("#log-statistics-block > summary").click();
|
||||
await page.waitForTimeout(400);
|
||||
const confirmed = await page.evaluate(() => ({
|
||||
marks: [...document.querySelectorAll("#log-rows tr")]
|
||||
.map((row) => [...row.querySelectorAll("td")].at(-2)?.textContent ?? ""),
|
||||
bands: [...document.querySelectorAll("#log-statistics-rows tr")]
|
||||
.map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent)),
|
||||
}));
|
||||
assert.equal(confirmed.marks.filter((mark) => mark === "✓").length, 1,
|
||||
`the QSL column reads ${JSON.stringify(confirmed.marks)}`);
|
||||
const twenty = confirmed.bands.find((band) => band[0] === "20m");
|
||||
assert.ok(twenty, `the bands are ${JSON.stringify(confirmed.bands)}`);
|
||||
assert.equal(twenty[3], "1", `20m shows ${twenty[3]} confirmed`);
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -309,6 +309,49 @@ export async function startWebFixture({
|
||||
response.end(JSON.stringify({ call, worked }));
|
||||
return;
|
||||
}
|
||||
if (tail === "/statistics") {
|
||||
const bands = new Map();
|
||||
for (const qso of logbook) {
|
||||
const entry = bands.get(qso.band) ?? { band: qso.band, contacts: 0, stations: 0, confirmed: 0, calls: new Set() };
|
||||
entry.contacts += 1;
|
||||
entry.calls.add(qso.call);
|
||||
if (qso.confirmed || qso.qsl_rcvd === "Y" || qso.lotw_qsl_rcvd === "Y") entry.confirmed += 1;
|
||||
bands.set(qso.band, entry);
|
||||
}
|
||||
const list = [...bands.values()].map(({ calls, ...rest }) => ({ ...rest, stations: calls.size }));
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({
|
||||
contacts: logbook.length,
|
||||
confirmed: list.reduce((total, band) => total + band.confirmed, 0),
|
||||
bands: list,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (tail === "/export.cbr") {
|
||||
const contest = url.searchParams.get("contest");
|
||||
const entered = contest ? logbook.filter((qso) => qso.contest_id === contest) : logbook;
|
||||
response.writeHead(200, { "content-type": "text/plain" });
|
||||
response.end(`START-OF-LOG: 3.0\nCONTEST: ${contest ?? ""}\n`
|
||||
+ entered.map((qso) => `QSO: 14200 PH ${qso.call}\n`).join("")
|
||||
+ "END-OF-LOG:\n");
|
||||
return;
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
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 id = tail.replace("/", "");
|
||||
const held = logbook.find((qso) => qso.id === id);
|
||||
if (held) {
|
||||
Object.assign(held, input, { id, confirmed: input.qsl_rcvd === "Y" });
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(held ?? {}));
|
||||
return;
|
||||
}
|
||||
if (tail === "/export.adi") {
|
||||
const body = logbook
|
||||
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
|
||||
@@ -337,6 +380,10 @@ export async function startWebFixture({
|
||||
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
|
||||
my_rig: input.my_rig,
|
||||
operator: input.operator,
|
||||
contest_id: input.contest_id ?? null,
|
||||
stx: input.stx ?? null,
|
||||
srx: input.srx ?? null,
|
||||
confirmed: input.qsl_rcvd === "Y" || input.lotw_qsl_rcvd === "Y",
|
||||
};
|
||||
logbook.unshift(qso);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
|
||||
Reference in New Issue
Block a user