Architecture

EzFD is a Next.js 16 App Router application (standalone output, run as node server.js) on PostgreSQL 16, with Tailwind v4 for styling. Dark is the default theme, so light-mode styles carry the light: prefix.

There are four ideas worth understanding; the rest is ordinary CRUD.

Real-time updates

Every operator needs to see every QSO immediately, including for duplicate checking. Rather than polling, the app uses PostgreSQL's own notification system.

QSO INSERT ─→ trigger ─→ pg_notify('qsos_<event_id>')
                              │
                    ┌─────────┴─────────┐
                    │  /api/realtime     │  a dedicated pg connection
                    │  LISTENs on both   │  per connected client
                    │  channels          │
                    └─────────┬─────────┘
                              │  Server-Sent Events
                    ┌─────────┴─────────┐
                    │  every browser     │
                    └────────────────────┘

Two channels per event: qsos_<id> for QSO changes and ses_<id> for special event checkouts. One SSE endpoint serves both, tagging each message with an event name so the client can tell them apart. That keeps it to one connection per browser window rather than one per data type.

Consequences:

  • A connection pooler in transaction mode breaks this silently. See Configuration.
  • nginx must not buffer the SSE response. The app sets X-Accel-Buffering: no; the deployed nginx config disables buffering too.
  • The stream sends a keepalive comment every 25 seconds so proxies don't time it out.

Offline tolerance

Field Day sites have unreliable networking, so the log is written locally first and synced second.

operator logs ─→ localStorage queue ─→ POST /api/qso ─→ dequeue on success
                        │                     │
                        │                     └─ on failure: stays queued
                        └─ shown immediately, marked "Queued — syncing…"

The queue flushes on reconnect and can be retried manually. A QSO leaves the queue only when the server confirms it — an earlier version checked the wrong value and dropped QSOs whenever a submit failed, which is exactly the failure this design exists to prevent.

Replayed QSOs carry a replay flag. The server accepts those unconditionally, bypassing the special event checkout and approval gates, because by reconnect time the reservation has expired and the contact already happened on the air. Refusing it would only lose the record.

Special event call checkout

The rule is one signal per band per mode. Two operators claiming the same slot at the same instant is a genuine race, and no application-level check closes it — between the SELECT that finds the slot free and the INSERT that claims it, the other operator can do both.

So the database enforces it:

CONSTRAINT ses_no_overlap EXCLUDE USING gist (
  event_id WITH =, band WITH =, mode WITH =, during WITH &&
) WHERE (status <> 'RELEASED')

during is a tstzrange. The constraint makes overlapping rows impossible, so the loser of a race gets SQLSTATE 23P01 and the route turns that into a 409 naming the current holder. It also rejects an extension that would run into someone else's slot, which is the desired behaviour rather than a bug.

btree_gist is required for the = comparisons to sit alongside the range overlap test in one GiST index.

See Database.

Rig control

The bridge runs entirely on the operator's machine:

radio ──serial──→ rigctld ──TCP──→ ezfd-rig-bridge.py ──WebSocket──→ browser
                                        (localhost:4575)

The server is not involved and never learns anything about the radio. Both the main logging tab and the CW popout open their own connection to the bridge via a shared hook.

All rigctld I/O is serialised behind one lock, because polling and CW commands share a connection and interleaving them corrupts both.

Layout

app/
  api/            route handlers
  event/[code]/   join, log, dashboard, CW popout
  event/new/      event creation
components/       React components, all client-side
lib/              shared logic — scoring, exports, db, types
db/               schema and its tests
scripts/          test suites and the WSJT-X relay

lib/ holds anything that is or could be shared between routes and components. Notable pieces:

File Responsibility
scoring.ts The ARRL formula. The single source of truth for score
adif.ts ADIF import parsing and export generation
cabrillo.ts Cabrillo export (contest events only)
ses.ts Checkout queries, the 23P01 constant, slot formatting
events.ts Shared event column list, and the dupe rule
offline-queue.ts The localStorage queue
useRigBridge.ts WebSocket hook shared by both logging windows
types.ts Domain types, band and mode lists, section list

Things that bite

Collected here because they've each cost real debugging time. AGENTS.md carries the full list for anyone changing the code.

pg returns TIMESTAMPTZ as JavaScript Date objects, not strings. Export and formatting code has to handle both shapes. Interpolating one into a message produces Sat Aug 22 2026 02:24:29 GMT+0000 (Coordinated Universal Time) rather than a time an operator can read.

Sections do not multiply the Field Day score. This has been implemented wrongly more than once. See Field Day.

Inline arrow-function props recreate identity every render. A parent that re-renders on every rig frequency tick — four times a second — passing foo={() => …} to a child whose effect depends on it will retrigger that effect continuously. This once prevented an auto-CQ timer from ever completing an interval. Memoise in the parent, or read the current value through a ref inside long-lived timers.

json_agg over zero rows serialises as JSON null — a scalar, not SQL NULL — so COALESCE(x, '[]') doesn't catch it and jsonb_array_elements fails. Check jsonb_typeof instead.