Development
Local setup
Requirements: Node 24 (the major in .nvmrc — CI and deploy.sh both read it
from there), PostgreSQL 16.
$ git clone https://github.com/nreed97/EzFD.git
$ cd EzFD
$ npm install
$ createdb ezfd
$ psql -d ezfd -f db/schema.sql
$ cat > .env.local <<'EOF'
DATABASE_URL=postgres://localhost:5432/ezfd
EZFD_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
EOF
$ npm run dev
The encryption key above is fine locally; generate a real one with
openssl rand -hex 32 for anything else.
db/schema.sql is idempotent, so re-applying it after a schema change is the
normal workflow rather than a migration step.
Before finishing a change
$ npx tsc --noEmit
$ npm run build
Both must be clean. This is the gate AGENTS.md sets and CI enforces.
Type scale and touch targets
Font sizes come from three tokens defined in app/globals.css, not from
arbitrary values:
| Token | Size | Use |
|---|---|---|
text-2xs |
11px | Labels, badges, secondary detail |
text-xs |
13px | Body — most of the interface |
text-sm |
15px | Headings, buttons, the entry form |
Anything larger (text-lg up) is a display number — a score or a rate, sized
to be read across a tent — and is left alone.
There used to be thirteen distinct sizes, four of them within two pixels of
each other below 12px. scripts/test-nav.cjs fails on a text-[13px]-style
arbitrary value, and on a token defined without its matching
--text-*--line-height, because Tailwind v4 ties each line height to its own
size and overriding the size alone leaves the old leading behind.
The .tap class raises a control to 44px only under pointer: coarse. A
laptop keeps the density a logger wants; a phone gets targets a thumb can hit.
Reach for it on any new control small enough to miss.
Tests
Nineteen suites, all run by CI. They come in two kinds, and the split is worth knowing when you are deciding what to run before a commit.
Fourteen need nothing at all — no database, no build, no server. They cover pure functions, so they run in about a second and are the ones to reach for first:
$ node scripts/test-sections.cjs # the ARRL/RAC section list, in all three places
$ node scripts/test-overview.cjs # the site display's band coverage
$ node scripts/test-scoring.cjs # the ARRL formula, bonuses and their caps
$ node scripts/test-gota.cjs # GOTA counts twice, and its count comes from the log
$ node scripts/test-preflight.cjs # the pre-submission read, and rule 7.3's class column
$ node scripts/test-adif.cjs # ADIF parse and export
$ node scripts/test-cabrillo.cjs # Cabrillo submission
$ node scripts/test-log-filters.cjs # the dashboard log view
$ node scripts/test-op-stats.cjs # who worked what, and that the rows add up
$ node scripts/test-nav.cjs # the menu — one list for every surface and width
$ node scripts/test-slot-board.cjs # the operating position board
$ node scripts/test-last-position.cjs # what the position picker preselects
$ node scripts/test-changelog-links.cjs # the changelog's links into these guides
$ node scripts/test-docs-nav.cjs # the /docs sidebar and its reading order
$ node scripts/test-cat-protocol.cjs # Kenwood CAT decoding for native rig control
Five need a database, or a running server:
# The SES overlap guarantee lives in a database constraint,
# so it's asserted against a real database
$ psql -d ezfd -v ON_ERROR_STOP=1 -f db/test-ses-constraint.sql
# Route SQL is built as strings, which the typechecker can't see into
$ DATABASE_URL=postgres://localhost/ezfd node scripts/test-queries.cjs
# ezfd-admin.sh backup/restore, round-tripped for SES, FD and empty events
$ PSQL="psql -h localhost -U postgres" bash scripts/test-restore.sh
# Merging two instances of one event back into a single log
$ DATABASE_URL=postgres://localhost/ezfd node scripts/test-merge.cjs
# The API end to end, against a running server
$ BASE_URL=http://localhost:3000 bash scripts/test-e2e.sh
# Which machines deploy.sh will install on
$ bash scripts/test-deploy-detect.sh
# The Docker Compose install: the image and proxy settings on their own, and
# with BASE_URL set, live updates and shutdown through a running stack
$ bash scripts/test-docker.sh
$ BASE_URL=http://localhost bash scripts/test-docker.sh
What each is for
db/test-ses-constraint.sql asserts rather than prints, so a broken
constraint fails the build instead of scrolling past. Covers overlap
rejection, same-band-different-mode, extension into the next holder, early
release, and cancelling a not-yet-started slot.
scripts/test-queries.cjs exercises route SQL through the real pg
driver. These queries are assembled as strings, so an interval cast that only
works with a text parameter, or a range bound that returns a Date rather
than a string, fails at runtime and nowhere earlier.
scripts/test-restore.sh round-trips the admin console's backup and
restore. Export and restore are ezfd_export_events() and
ezfd_restore_events() in db/schema.sql, so the test exercises the shipped
definitions rather than a copy — the same ones the HTTP API and the console
call. It used to carry its own third variant of the backup query, and so
round-tripped a shape the console's menu action never produced, staying green
while that action silently dropped the SES roster.
scripts/test-deploy-detect.sh covers the one decision deploy.sh makes
before it touches anything: whether this is a machine it supports. It reads the
detection block back out of deploy.sh rather than keeping a copy, and drives
it with real /etc/os-release contents from nine distributions.
Both directions are quiet failures. Believe a claimed Debian heritage on a machine with no apt and the install dies halfway through, having already written part of a configuration; refuse a derivative like Mint or Pop!_OS and an operator is told their perfectly capable machine is unsupported.
The suite also holds the scope in place. Everything deploy.sh does after the
pre-flight assumes Debian's layout, so an unsupported machine has to be turned
away rather than carried along — and carrying it along fails silently, since a
server block written where nginx never looks still passes nginx -t. So the
suite asserts the refusal is fatal rather than a warning, that it happens
before the operator has answered four prompts, and that no second
prerequisite-checking path has reappeared below it. That last one is the drift
this design can have: a path that quietly supports what the script says it
does not.
scripts/seed-demo.mjs is not a test either: it is the fixture the
documentation screenshots are captured against. Point it at a running server
and it creates a Field Day event and logs a weekend into it through the real
API, so scoring, dupes and section counting are computed the way an event
computes them.
What it is careful about is the part a fixture written for the database gets
wrong. Posting 260 contacts in a loop stamps them all within two seconds, so
the rolling-hour rate panel reports the whole log instead of a rate — and it is
datetime_utc that panel and the log read, not created_at, so spreading only
the audit stamp fixes nothing visible. Cycling bands with a fixed stride gives
every band an identical count, which reads as synthetic at a glance. Operators
get presence rows so the panels fed by presence show a live state. The section
list stops short of a clean sweep, because the gaps are what the Needed view
and the map's unworked fill are for. It is seeded deterministically, so a
re-taken screenshot differs only where the app changed.
scripts/test-map-labels.cjs covers which section labels the map draws at
which zoom. At the default zoom 54 of the 85 labels overlapped another one, so
lib/mapLabels.ts keeps a label only when its box clears the ones already
placed.
Two of its properties are worth a suite and neither shows up in a screenshot.
Placement must not read whether a section is worked, or the map twitches while
people are logging — so the module has no notion of it, and the test greps for
that as a substring, since \bworked\b matches neither isWorked nor
workedFirst. And zooming in must never take a label away, which does not
fall out of greedy placement: a label blocked at one zoom becomes placeable at
the next and can evict one that had been drawn all along. VT and DE vanished
between zoom 3 and 4 in the first implementation, and ENY between 4 and 5, so
zooming in to read a label made it disappear. Each level now starts from the
previous level's set and only fills the gaps around it.
scripts/build-section-geo.mjs is not a test but belongs next to them:
it regenerates public/sections.geo.json, the section boundaries the map
draws. Run it after changing the section list, the county table or the Ontario
census-division table.
$ node scripts/build-section-geo.mjs
public/sections.geo.json 164 KB
85 sections with a boundary
every section has a boundary
1 drawn as shared, split by something that is not an administrative line: Nipissing District
85 sections accounted for
It refuses to produce a file that does not account for every section, and
verifies both tables in both directions: a name no county or census division
answers to fails, and so does a county or division that no section claims —
the second being the one that leaves a hole in the map and is invisible in a
picture. The first run fetches the Canadian source and caches it under
.cache/; later runs need no network. That source is pinned by commit and
checked against a SHA-256, because a pinned URL only says where bytes came
from — a boundary file that changed underneath would redraw sections without
saying so.
Two things about it are worth knowing before changing it.
Every Canadian section comes from one file, Statistics Canada's census divisions, provinces included. Ontario's four have to come from there because RAC carves them by census division; the provinces followed because when the neighbours came from Natural Earth instead, the two outlines did not coincide and a strip belonging to neither opened along the border — 1.9% of the Ottawa River border and 4.8% of the Manitoba one, measured by rendering the file over a red background and counting what showed through. One source per country means every internal border is the same arcs on both sides.
Simplification is an absolute tolerance, not a percentile. It used to keep
"the most significant tenth of the vertices", which measures the file rather
than the map: adding a denser source makes the cut-off finer for everything
else. Adding Ontario's census divisions took the arc count from 22,930 to
392,019, dropped the threshold from 0.049 to 0.0000054, and tripled the size
of the Northern Territories without anything about the Northern Territories
changing. SECTION_GEO_TOLERANCE overrides it; below about 0.02 the file
stops shrinking usefully, and above about 0.15 Michigan's Upper Peninsula and
the Delmarva start to mangle.
scripts/test-sections.cjs then checks the built file from the outside: every
section present exactly once, DX absent, no ring wrapping the antimeridian
(the symptom of a source that does not split at 180°, which renders as a band
smeared across the world), and the asset under 400 KB.
scripts/build-basemap.mjs regenerates public/basemap.geo.json, the land
the sections are drawn on. Run it only if you change the tolerance or the
source.
$ node scripts/build-basemap.mjs
unwrapped 3 ring(s) across the antimeridian, dropped 8 below -60°
public/basemap.geo.json — 1 feature(s), 118 rings, 3900 vertices, 56 KB
The map used to fetch raster tiles — CARTO's, then OpenStreetMap's. Carrying its own ground instead answered three separate problems at once:
- A distributed app should not point every install at a third party. CARTO was open and then was not, and the refusal arrived as "API key required" rendered into the tile image — a failure that is a picture rather than an error. OSM removed the key but not the dependency: theirs is a volunteer service with a usage policy, and EzFD is cloned and deployed by whoever wants it.
- Firefox. Measured on a running event, panning the map with tiles dropped 11 of 319 frames at a p95 of 30 ms; with no tiles and everything else identical, 0 of 517 at 6.1 ms. Neither the renderer (SVG and Canvas measured the same), nor Leaflet's tile options, nor forcing the tile pane onto its own compositor layer moved it. The cost is Firefox repainting raster tiles under a pan transform.
- Offline field servers, where the tiles never loaded at all.
The source is Natural Earth 110m land via the world-atlas npm package,
pinned in package.json — public-domain, and the same shape of source the
sections come from. Land rather than countries: countries-110m is 145 KB and
177 features against 56 KB and one, it would draw Mexico's border as if it
meant something here, and one feature is one SVG path.
BASEMAP_TOLERANCE is an absolute tolerance for the same reason
SECTION_GEO_TOLERANCE is.
The one subtlety is the antimeridian, and it bit twice. Natural Earth keeps
every longitude inside ±180, so the ring carrying Afro-Eurasia steps straight
from +179 to -179 where Russia crosses the date line — drawn literally, that
is a line back across the whole world. Walking each ring and carrying an
offset fixes it, and then the offset has to be taken back out: that ring starts
in Chukotka, so every point after the crossing picks up -360 and Europe and
Africa end up written at around -350°, which Leaflet draws exactly where it
says — one world-width to the left, leaving an empty Eastern Hemisphere behind.
scripts/test-sections.cjs asserts both: no step over 180° between
consecutive vertices, and no longitude outside ±200° (a little overrun is real,
since Chukotka reaches 190°).
The colours are in components/MapView.tsx, not here, and two of them are not
free choices: #f2efe9 and #1c1a16 are what the old raster basemap
rendered as underneath the section fills, and the worked/unworked border
contrast was calibrated against those exact values.
scripts/test-merge.cjs covers ezfd_merge_event() and
ezfd_recompute_dupes() — reconciling an event that ran in two places at once
into one log. Every case that matters is a way to silently change what a club
submits: a contact counted twice or dropped where the copies overlap, dupe
flags left as each instance computed them (both wrong for the union), a
deletion undone by the other copy, two unrelated events merged because nothing
checked identity, or an edit made on both sides resolved in favour of one
without saying so.
The subtle one is the deletion. A contact deleted here and still live there can be matched two ways — by its preserved id, or by the ±2 minute window when the instances logged it independently — and only the second reaches the query that could resurrect it. A test that only covered the first passed against a merge that had the bug, which is why both paths are asserted.
It also greps ezfd-admin.sh for three things a round trip cannot see,
because each of them failed silently:
| Guard | Catches |
|---|---|
No SELECT e.* |
A hand-rolled export that leaks the encrypted QRZ credentials, as two of the four earlier copies did |
No lax PG -v payload= |
A payload call without ON_ERROR_STOP, which makes a failed restore exit 0 and report success |
No IFS='|' read |
A row reader splitting on a pipe, which a club name containing one silently shifts out of alignment |
The middle one is worth a note on how to write this kind of guard. The first
version checked that PGS appeared somewhere in the file — and passed even
with the restore call reverted, because the count above it also uses PGS and
satisfied the grep on its own. A guard over a file with two call sites has to
assert the absence of the bad form, not the presence of the good one.
scripts/test-e2e.sh drives the API against a running server: checkout
conflicts, both enforcement modes, the offline replay bypass, roster approval,
per-operator ADIF, and Field Day regressions.
scripts/test-docs-nav.cjs covers the /docs sidebar, which is built by
reading the groups and their order back out of docs/README.md rather than
declaring them a second time. That keeps the index and the sidebar from
drifting, at the cost of one failure the old alphabetical list could not have:
a guide can fall out of the navigation altogether — a mistyped index row, a
reformatted table — and still exist, still be reachable by URL, and be
invisible in the app. The test asserts every guide appears exactly once.
scripts/test-changelog-links.cjs checks that every guide the changelog
points at exists and that every #anchor lands on a real heading. A renamed
section leaves the link resolving to the top of the page, which looks fine in
a diff and wastes the reader's time; nothing else in the repository would
notice. It reproduces the slug rules in lib/docs.ts so the anchors are
checked the way both GitHub and /docs will resolve them.
Writing a test
Check it can fail. Break the thing it guards, watch it go red, put it
back. This isn't ceremony — doing it is what revealed that re-applying
schema.sql couldn't restore the overlap constraint, because CREATE TABLE IF NOT EXISTS skips the whole statement including its inline constraints.
A test that has never been observed failing is a test you don't know works.
CI
.github/workflows/ci.yml, four jobs:
| Job | Runs |
|---|---|
build |
The fourteen pure suites, then lint, typecheck and build, then the end-to-end suite against the built server |
schema |
Schema applied twice for idempotency, then the constraint, query and restore suites |
shell |
bash -n on every tracked .sh, the rig-bridge copy check, then shellcheck |
docker |
Builds the Compose install from scratch, then the live stack checks and the end-to-end suite through its proxy |
Everything is gated. Lint and shellcheck were advisory for a while, held
back by a backlog of pre-existing findings that would have failed every pull
request. That backlog is cleared and both are enforced, so anything either one
reports now is something the change introduced.
The pure suites run first in build because they need no database and no
compile: a drifted section list or a broken scorer fails in seconds rather
than after the build and a server start.
Conventions
AGENTS.md in the repository root is the authority and is worth reading
before changing anything non-obvious. The highlights:
Next.js 16 App Router, standalone output. Route handlers take
params: Promise<...> — await them.
Tailwind v4, dark by default. Light-mode styles use the light: prefix,
which is the opposite of the usual convention.
Bash scripts use set -uo pipefail without -e. -e terminates
interactive menus on the first non-zero return. Use [[ ]] rather than
(( )) for comparisons — (( )) returns exit 1 on a false result, which
under -e-style handling reads as failure. Always local var="", never bare
local var, to avoid unbound-variable errors under set -u.
public/ezfd-rig-bridge.py is a manual copy of the root script, served
for direct download. They are not symlinked; copy the root file over the
public one after editing.
Where things live
See Architecture for the layout and the shared
modules in lib/.
Gotchas that have cost real time
The full list is in AGENTS.md. The ones most likely to catch you:
pgreturnsTIMESTAMPTZasDateobjects, not strings- Sections do not multiply the Field Day score
- Inline arrow-function props retrigger child effects on every parent render, and the logging screen re-renders four times a second under rig control
json_aggover zero rows is JSONnull, a scalar, whichCOALESCEmisses- The SES checkout constraint must stay at band+mode granularity