D: live in-browser contest logging (logger, lifecycle, conversion, EDI export) #10

Merged
hermes-agent merged 12 commits from feature/live-logging into main 2026-10-25 22:17:28 +00:00
Owner

Implements checklist D1-D6: an entrant can log a contest live in the browser and the result is a normal Entry.

What this adds

The logger (D2). A single horizontal entry bar in the style of Wavelog: frequency first, band derived from it and never typed (432.2 MHz can never wear a 6m badge), mode restricted to the reglement's three modes (FM, SSB, CW - verified in the reglement, USB/LSB normalised to SSB, anything else refused server-side), call, RST sent/received, serials sent/received, other side's locator. Rows support true inline editing with a save/cancel pair, and a menu offering exactly Edit or Delete - no mimicked-green-dot affordances.

Session lifecycle (D3). The old abandoned state is gone; the owner called it a trap, and it was: it stranded the QSOs with no way back and no manager visibility. Ending a session now pauses it (ended), and the same callsign + PIN reopens the very same log. A session closes itself (closed, read-only) once the contest's submission window passes. Migration 0006 adds both states to the Postgres enum inside an autocommit block (ALTER TYPE cannot run in a transactional DDL step) and converts every legacy abandoned row to ended.

Manager visibility (D2/D3). A live session is not an Entry, so it used to be invisible in the manager UI while sitting in the database. The dashboard now links to a read-only list per Monthly Contest (callsign, section, locator, state, QSO count, bands, last QSO, start time) with a read-only drill-in to the logged QSOs. No scoring, no flags, no mutations, and the page says these are not entries yet.

Conversion (D4). /live/finish collects only what a session cannot (email twice, PIN, Declaration, operator, address, afdeling) and calls ingest_parsed_log with a generated Cabrillo log - so scoring, flags, the one-Entry invariant and the raw-file-first rule all apply unchanged. The generated log is written to data/app/uploads/ as the recovery anchor, so a live Entry stays rebuildable from raw files. Refusals: no QSOs, wrong PIN, mismatched emails, missing Declaration, closed submission window (a late log is a manager Override, which this path cannot perform).

EDI export (D5). One REG1TEST file per band, because EDI is single-band by definition. Mode codes are IARU R1 (Vienna 1998) 4.12.2 values, checked against the standard after I got them wrong from memory: 6 = FM, 1 = SSB, 2 = CW; 4 is CW-TX/SSB-RX cross-mode and is never written for FM. Unknown modes become an empty column (allowed by the spec) instead of a wrong code. Lines are validated against the 75-character limit, and points are left unclaimed because the adjudicating software recomputes them. Downloadable by the manager from the entry page, and by the entrant from the conversion confirmation gated by the browser session rather than a PIN in the URL.

Verification

  • 277 tests (up from 182 on main), including the parity test that reparses the generated Cabrillo and asserts the same QSOs, bands, serials and modes come back - conversion is the same as uploading, not a parallel path.
  • Exercised on a live stack against real Postgres with a real session (PD1SCO/B/JO22EB): 5 QSOs logged, session converted to Entry #1 (score 25), anchor file written, per-band EDI downloaded and reparsed.

Deliberately not in this PR

  • Client-side offline queue: owner decision, low priority, deferred to future-work with the idempotency cost written down.
  • Band.HIGHER collapses everything above 440 MHz into one band, so EDI cannot declare PBand for 23cm/6cm. Flagged in future-work as a Contest Manager question, not silently invented.

Known issue this PR does not fix (needs a decision)

A second upload for the same contest + callsign + section replaces the first. Because EDI is natively one file per band, an entrant who uploads 2m and then 6m currently ends up with only the 6m QSOs. That predates this branch and is untouched here; it needs a decision (merge per band vs one Entry per band) before real EDI submissions arrive.

Implements checklist D1-D6: an entrant can log a contest live in the browser and the result is a normal Entry. ## What this adds **The logger (D2).** A single horizontal entry bar in the style of Wavelog: frequency first, band derived from it and never typed (432.2 MHz can never wear a 6m badge), mode restricted to the reglement's three modes (FM, SSB, CW - verified in the reglement, USB/LSB normalised to SSB, anything else refused server-side), call, RST sent/received, serials sent/received, other side's locator. Rows support true inline editing with a save/cancel pair, and a menu offering exactly Edit or Delete - no mimicked-green-dot affordances. **Session lifecycle (D3).** The old `abandoned` state is gone; the owner called it a trap, and it was: it stranded the QSOs with no way back and no manager visibility. Ending a session now *pauses* it (`ended`), and the same callsign + PIN reopens the very same log. A session closes itself (`closed`, read-only) once the contest's submission window passes. Migration 0006 adds both states to the Postgres enum inside an autocommit block (ALTER TYPE cannot run in a transactional DDL step) and converts every legacy `abandoned` row to `ended`. **Manager visibility (D2/D3).** A live session is not an Entry, so it used to be invisible in the manager UI while sitting in the database. The dashboard now links to a read-only list per Monthly Contest (callsign, section, locator, state, QSO count, bands, last QSO, start time) with a read-only drill-in to the logged QSOs. No scoring, no flags, no mutations, and the page says these are not entries yet. **Conversion (D4).** `/live/finish` collects only what a session cannot (email twice, PIN, Declaration, operator, address, afdeling) and calls `ingest_parsed_log` with a generated Cabrillo log - so scoring, flags, the one-Entry invariant and the raw-file-first rule all apply unchanged. The generated log is written to `data/app/uploads/` as the recovery anchor, so a live Entry stays rebuildable from raw files. Refusals: no QSOs, wrong PIN, mismatched emails, missing Declaration, closed submission window (a late log is a manager Override, which this path cannot perform). **EDI export (D5).** One REG1TEST file per band, because EDI is single-band by definition. Mode codes are IARU R1 (Vienna 1998) 4.12.2 values, checked against the standard after I got them wrong from memory: **6 = FM**, 1 = SSB, 2 = CW; 4 is CW-TX/SSB-RX cross-mode and is never written for FM. Unknown modes become an empty column (allowed by the spec) instead of a wrong code. Lines are validated against the 75-character limit, and points are left unclaimed because the adjudicating software recomputes them. Downloadable by the manager from the entry page, and by the entrant from the conversion confirmation gated by the browser session rather than a PIN in the URL. ## Verification - **277 tests** (up from 182 on main), including the parity test that reparses the generated Cabrillo and asserts the same QSOs, bands, serials and modes come back - conversion is the same as uploading, not a parallel path. - Exercised on a live stack against real Postgres with a real session (PD1SCO/B/JO22EB): 5 QSOs logged, session converted to Entry #1 (score 25), anchor file written, per-band EDI downloaded and reparsed. ## Deliberately not in this PR - Client-side offline queue: owner decision, low priority, deferred to future-work with the idempotency cost written down. - `Band.HIGHER` collapses everything above 440 MHz into one band, so EDI cannot declare `PBand` for 23cm/6cm. Flagged in future-work as a Contest Manager question, not silently invented. ## Known issue this PR does not fix (needs a decision) A second upload for the same contest + callsign + section **replaces** the first. Because EDI is natively one file per band, an entrant who uploads 2m and then 6m currently ends up with only the 6m QSOs. That predates this branch and is untouched here; it needs a decision (merge per band vs one Entry per band) before real EDI submissions arrive.
A live session is an entrant's in-progress log for one Monthly Contest, held in
its own tables until conversion, with the same callsign + 5-digit PIN identity as
an uploaded Entry. Nothing here is scored or visible to the manager: D4 converts
through ingest_parsed_log, so a live session and an uploaded file take the same
path through scoring, flags, the one-Entry invariant and the audit trail.

- live_session: contest, callsign, section, pin_hash, own locator, status
  (open/converted/abandoned), timestamps, converted_entry_id.
- live_qso: sequence, band, mode, UTC date/time, and exactly the exchange the
  reglement asks for — worked callsign, sent RST + serial, received RST + serial,
  received locator. The entrant's own locator lives on the session.
- Section rules for the running serial: A/B number continuously across bands,
  C/D number per band (serial_for()).
- A partial unique index allows exactly one *open* session per
  (contest, callsign, section) while letting an entrant accumulate converted and
  abandoned ones.

Migration 0004 verified against Postgres 18: the section/band types are reused
without re-creating them (postgresql.ENUM create_type=False), the new status type
is created by CREATE TABLE and dropped on downgrade, and the partial index lands
as ... WHERE status = 'open'.
An entrant opens a session with callsign + PIN and logs QSOs while the contest
runs, with exactly the exchange the owner asked for: worked callsign, sent RST +
serial, received RST + serial, and the other side's locator. Their own locator is
fixed on the session.

- Band is derived from the entered frequency, never typed as a band; an unknown
  frequency is refused with an explanation instead of guessing.
- The running serial follows the Section: continuous across bands in A/B, per
  band in C/D, and an explicitly typed serial wins over the suggestion.
- A duplicate (same station, same band) is warned inline and still logged — a
  warning is not a block, and E3/adjudication stays the manager's business.
- Sessions persist server-side and the id lives in the signed web session, so a
  reload, a lost cookie or a second tab resumes the same log rather than forking
  it; rejoining needs the same PIN.
- Delete renumbers the log so the serial sequence stays predictable; Abandon
  closes the session without converting it.
- New: app/web/routes_live.py, entrant/live_start.html + live_log.html, 38 msgids
  in both catalogs, a link from the upload page.

Tests: 236 (was 217), 19 of them new here — band derivation, per-Section serials,
dupe warning, PIN rejoin, delete renumbering, abandon, lost-cookie resilience.
The vertical form was unusable for real contest logging. This follows the logger
the owner actually uses (Wavelog): one row — frequency, callsign, RST sent/received,
serials sent/received, both grids — tab across, Enter logs. The owner asked for
frequency and band to be *in* the bar, so frequency is the first field and the band
is derived and shown, never typed; a small script previews the band while typing
and the server remains the authority.

- The recent-QSO table now mirrors Wavelog's columns (date, time, callsign,
  frequency, band, mode, RST sent/received, Nr S/R, grid) with a green/amber dot
  per row and an inline row editor (⋮) for the fields an operator mistypes under
  pressure, plus delete.
- The frequency is stored in kHz (migration 0005) so the log shows what was typed;
  it sticks from the previous QSO, and the serial is pre-filled with the next value
  for the Section.
- The panel gets the full page width (body.logger) — a bar that wraps to two lines
  costs keypresses.
- New msgids in both catalogs; 13 bar labels.

Tests: 236 pass (the dupe test now asserts the dot markers and the header count).
My defect, and it made the panel unusable in the owner's (dark) OS scheme. The
app declares color-scheme: light dark, so the browser paints body text white and
form controls dark; the bar I added hardcoded a light background, a light field
background and dark label colours, but set no text colour on the inputs. Result:
white text on white fields (the frequency looked empty even though it held
144.3), and a light panel floating in a dark page — two themes on one screen.

- Added theme tokens (panel/field/badge/muted, light + prefers-color-scheme: dark)
  and every logger colour now comes from them, with an explicit color paired to
  every background.
- Placeholder and focus colours tokenised; the duplicate dot uses the ok/dupe
  tokens so it stays visible in both schemes.
- Same class of bug fixed on the cross-log report: tr.informational used a fixed
  #666, which is unreadable on a dark background.

Verified by emulating both schemes in a real browser: light = white field on
#f4f4f7 with near-black text; dark = #12141a field with near-white text; the
frequency value is visible in both. 236 tests pass.
The owner rejected the green status dots: I had copied a Wavelog indicator
without knowing what it means, so they are gone rather than guessed at.

- The ⋮ now opens a two-item menu: Edit and Delete (nothing else).
- Edit turns *that row* into inputs in place — date, time, callsign, frequency,
  band, mode, RST, Nr V, Nr O, grid — with a green ✓ to save and a grey ✕ to
  cancel. Server-rendered, no JavaScript: ?edit=<id> renders the row, the inputs
  carry form="edit-<id>" so the save button can live in the last cell, and
  cancel is a plain link back to the log.
- The whole row is editable on purpose: a wrong call or band is as likely as a
  wrong serial. The band follows the frequency (the dropdown only decides when the
  frequency is unusable), and RST is one field that writes both directions since
  VHF exchanges are 59 both ways.
- Time now accepts seconds as well as HHMM (19:27:32, 192732, 19:27 all work),
  keeps the precision typed, and displays it back with colons. Conversion to an
  Entry will truncate to HHMM, which is what EDI uses.

Tests: 241 (was 236) — menu contents, single-row edit mode, full-row save
(callsign/band/frequency/mode/RST/serials/grid), seconds preserved, and a bad
edit rejected without touching the row.
Two fields that can disagree is a design fault: the bar's band badge was
hardcoded to 2m, so 432.3 could sit next to "2m", and the inline editor offered a
band <select> that the server silently overrode with the frequency. The owner's
rule: enter the frequency (144.3, 432.3 — precision is their business), the band
is inferred; a contradiction is impossible.

- The bar badge is rendered server-side from the prefilled frequency (correct
  with JavaScript disabled) and follows the field live as it is typed.
- The inline editor has no band field at all; it shows a derived badge next to the
  frequency, updated by the same script.
- The edit route always derives the band from the frequency; a hand-posted band
  value is ignored, so 432.200 can never be saved as 6m. An unusable frequency is
  refused instead of falling back to a guessed band.

Tests: 244 (was 241) — band follows the frequency across 432.2/50.15/144.3 even
when a contradicting band is posted, an unusable frequency is refused without
changing the row, and the bar badge renders from the last frequency server-side.
Verified in the browser: typing 432.3 shows 70cm, 50.15 shows 6m, zero band
selects on the page.
The bar had mode only as a hidden sticky value; it is now a real select. The
allowed set is not a guess: the reglement at vrza.nl says "Toegestane modes zijn
FM, SSB en CW", so AM, RTTY and the digital modes are out by rule. USB/LSB are
normalised to SSB because loggers label sideband that way and an entrant should
not be rejected for their logger's vocabulary; anything outside the three is
refused with an explanation.

- Bar: MODE select, sticky from the previous QSO (was a hidden input).
- Inline editor: MODE is the same three-option select, not free text.
- Server validation on both add and edit.
- Bar tightened (field widths, gaps) so the whole thing sits on ONE line at
  1280px with mode added — checked in the browser, not assumed.
- Checklist E11 now records the verified reglement wording, so the upload-side
  advisory flag can be built against a fact instead of a memory.

Tests: 249 (was 244) — exactly three options, each mode logs and stores, USB/LSB
-> SSB, FT8 refused without writing a row, mode sticks across QSOs.
The owner called abandon a trap, and it was: one bare button set a state that
stranded the QSOs — unreachable from the panel and invisible to the manager.

- 'Abandon' is gone. 'End session' pauses (status ended): the QSOs stay, the log
  stays reachable, and the same callsign + PIN reopens the very same session.
- A session closes itself (status closed, read-only, bar and row actions hidden)
  once the contest's submission window passes, which is the reglement's boundary
  and matches "close it automatically at the end of the contest period".
- Writes are refused on a session that is not open, so a paused or closed log
  cannot be mutated from a stale tab.
- Migration 0006 adds the two states to the Postgres enum (in an autocommit block,
  because ALTER TYPE cannot run in a transactional DDL step) and converts every
  legacy 'abandoned' row to 'ended' so nothing is left stranded.

Tests: 250 (was 249) — pause keeps the log and resume reopens the same session,
a paused session refuses new QSOs, and a session auto-closes when its window
passes.
The manager was blind to live logging: sessions sat in the database and appeared
nowhere in the UI, because a session is deliberately not an Entry. That is the
wrong trade — during a contest night the manager should see who is active.

- Dashboard: a "Live sessies (N)" link per Monthly Contest, only when sessions
  exist.
- GET /manager/contest/{id}/sessions: callsign, section, own locator, state
  (bezig/gepauzeerd/gesloten/omgezet), QSO count, bands used, last QSO, start time.
- GET /manager/contest/{id}/sessions/{session_id}: the logged QSOs read-only.
- Strictly read-only and labelled as such: no scoring, no flags, no mutations, and
  the page says these are not entries yet.

Tests: 256 (was 250) — manager-only access, list state and counts, detail QSOs,
dashboard link, repeated views change nothing, unknown session redirects.
A live session becomes a submission through the same path an upload takes:
app/documents/cabrillo_export.py renders the session as a Cabrillo log (multi-band
is native to it — EDI is one file per band, so it cannot anchor a multi-band
session), that file is written to data/app/uploads/ as the recovery anchor, and
the QSOs go through ingest_parsed_log so scoring, flags, the one-Entry invariant
and the raw-file-first rule all apply unchanged.

- /live/finish collects exactly what a session cannot: email (twice), PIN,
  Declaration, operator, address, afdeling. The PIN is verified against the
  session and handed to ingest, so a later re-upload behaves identically.
- Refusals: no QSOs, a PIN that does not match, mismatched emails, missing
  Declaration, and a closed submission window (a late log is a manager Override,
  which this path deliberately cannot perform).
- On success the session becomes CONVERTED with converted_entry_id, and /live/done
  shows the resulting Entry, score and version.
- The generated log is integer MHz per Cabrillo (decimals break the parser) and
  the time is truncated to HHMM, which is what the reglement and EDI use.

Tests: 266 (was 256), including the parity test that reparses the generated log
and asserts the same QSOs, bands, serials and modes come back.
app/documents/edi_export.py renders an Entry (or converted live session) as EDI,
one file per band, because EDI is single-band by definition and a session spans
bands. Available to the manager from the entry page and to the entrant from the
conversion confirmation; the entrant link is gated by the browser session that
performed the conversion, not by a PIN in a URL (a PIN in a link lands in history
and server logs, and an entry id alone lets anyone enumerate logs).

Mode codes come from IARU R1 (Vienna 1998) §4.12.2, checked against the standard
after getting them wrong from memory: 6 = FM/FM, 1 = SSB, 2 = CW, and 4 is
CW-TX/SSB-RX cross-mode — so 4 is never written for FM. Unknown modes are written
as an empty column, which the standard allows.

Also: lines are validated against the 75-character limit, points are left
unclaimed (the adjudicating software recomputes them), and Band.HIGHER exports with
an empty PBand plus a Remark because the model cannot tell 23cm from 6cm.

Docs: D5/D6 ticked; future-work gains the deferred client-side offline queue and
the Band.HIGHER question. Tests: 277 (was 266).
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
public/vrza-contestrobot!10
No description provided.