Skip to content

Latest commit

 

History

History
899 lines (754 loc) · 49.4 KB

PLAN.md

File metadata and controls

899 lines (754 loc) · 49.4 KB

Course Ops — Project Plan

Problem

There is no simple web map that shows marathon course routes alongside live APRS tracking of the ham radio operators supporting the event. Phone apps exist, and full situational-awareness platforms like TAK exist, but nothing sits in between that a radio club can stand up without significant effort.

Goals

  • One map showing all race courses (Full, Half, 10K), aid stations, and live ham positions
  • Real-time position updates with no page refresh
  • Standable-up by a club with minimal technical effort
  • Possible future: hosted as a service for other clubs

Non-goals (v1)

  • Public/spectator access — access is restricted to event staff roles
  • Transmitting on APRS — this application is receive-only, permanently
  • Radio/TNC hardware interfacing — APRS-IS covers both phone apps and RF via igates
  • Cross-event incident history

Users and roles

Role Access Notes
NCS (Net Control) Full read + write Multiple operators, typically sharing one workstation
Liaison Read-only (v1) Ham embedded with Public Safety / Medics; mobile phone use
Logistics Read-only (v1) Field team: traffic control, cone placement, teardown; mobile phone use

Access control for v1 is a long random URL per role — a bearer token, exactly as secure as the group text it is pasted into, which is appropriate for this data. No user accounts. Deliberately chosen so a volunteer whose phone dies can be re-admitted by re-sending a link, with no admin intervention.

Scale

Under 50 hams per event. A flat roster list; no grouping or virtualization needed. SQLite is nowhere near stressed.

Technology

Layer Choice Reason
Backend Python + FastAPI aprslib handles all four APRS position encodings
Parsing aprslib Mic-E alone would be a major time sink to hand-roll
Storage SQLite One file, no server for a club to install; free replay
Frontend Leaflet, no build step No npm toolchain in deployment; mature touch support
Packaging pip into a venv, systemd unit, Apache in front No container runtime for a club to install

Deliberately not used: Redis, Celery, Postgres, a JS build pipeline. None earn their place at this scale, and each is one more thing for a club to install.

Containers were planned and dropped. This table said "Docker Compose + plain pip" until Phase 8 actually shipped, which it did as a venv, a systemd unit and Apache - and the row went unreconciled for a while, promising something that did not exist.

The reasoning for dropping it: the deployment target is a club's ordinary VPS or a laptop, where Docker is a runtime to install and a networking layer to debug in front of an app that is one Python process and one SQLite file. It solves nothing at that scale. It becomes attractive at the point of hosting several clubs at once - see issue #5 - where reproducibility and isolation start paying for themselves. Not before.

Architecture

APRS-IS (rotate.aprs2.net:14580)
   |  ONE TCP connection, server-side buddy filter
   v
Ingest task (asyncio) -- parse --> SQLite
   |                                 |
   +------ in-process pub/sub -------+
                  |
                  v
        WebSocket /ws/event/{id} --> N browsers

One APRS-IS connection for the whole server, never one per browser. This is both an efficiency matter and an APRS-IS etiquette matter — the network bans clients that open many connections or reconnect in a tight loop.

Build phases

  1. Ingest — APRS-IS connect, parse, store. No UI. (complete)
  2. KML/KMZ import — additive, with a review screen for messy organizer files (complete)
  3. Live map — mobile-first, WebSocket updates, layer toggles (complete)
  4. Roster / NCS panel — dual status axes, staleness scoped to expects_aprs (complete)
  5. Course-relative position — "Full-back at mile 14.2" (complete)
  6. Incidents — pin, bib, status workflow, operator initials, role-gated writes (complete)
  7. Replay — dropped 2026-09-05; the report page answers the after-event questions
  8. Deployment — Apache reverse proxy, Let's Encrypt, systemd (complete; see docs/DEPLOYMENT.md)

Phase detail

Phase 2 — KML/KMZ import

Organizers supply multiple files (full course, half course, water stops), typically exported from different tools with inconsistent structure. Import is therefore repeatable and additive: upload a file, unpack (KMZ is a zip containing doc.kml), extract every Placemark, then present a review screen where each LineString is assigned to a course and each Point to a POI type.

Build the review screen despite it feeling like scope. Organizer KML reliably contains placemarks named "Untitled Path", routes split into several segments, and folders mixing water stops with parking. Auto-detection will be wrong often enough that a silent importer costs more time than the review screen does.

Also needed before mile markers mean anything: stitching multi-segment routes into one line, and reversing a line drawn finish-to-start.

The premise above has a hole, found 2026-09-10 (issue #108): some organizers supply nothing. A 5K, a parade or a vehicle race may have no file at all, and plenty of files that do arrive have the route but not the water stops. Import being the only way a place could exist meant those events could not be staffed on the map - and, less obviously, that a place a hand-drawn file put in the wrong spot could never be moved, because update_poi accepted every field except lat/lon.

So places are creatable and movable by hand, on the Places tab: an Add a place form, editable coordinates, and a map to click. This does not weaken "import may never create places" - that rule stops a FILE filing a parking lot as an aid station with nobody looking, and a person naming a place and giving it a position is the human decision it exists to require. Resolved #108.

Two things the map has to get right, both discovered by trying it:

  • It cannot assume a course or a place exists. The parade case has neither, so placeMapView falls back to the event's own centre and then to a country view - never an empty fitBounds, which throws. The centre was unreachable from the browser until the 2026-09-14 audit: only the CLI's --lat/--lon set it, so for exactly the event the picker exists for the map opened on the whole country. Two ways in now: an optional Map centre on the event form, and an import seeds it from the staged file's middle when the event has none - once, never overwriting a centre someone set.
  • Dragging a pin writes into the table rather than saving. The row goes dirty and the table's own Save button picks it up, because a second save scope on one screen is the shape of the bug that once cost a real user twelve renames.

Phase 3 — Live map (mobile-first)

The Liaison, Logistics and Shadow roles are the primary mobile case: outdoors, one-handed, on a phone that must survive a six-hour event.

  • Map is full-bleed; panels are bottom sheets in thumb reach
  • High-contrast light theme by default — thin grey-on-grey is invisible in sunlight
  • Markers differ by shape and size, not hue alone
  • Nothing hover-only, ever. Tap targets >= 44px
  • Never interpolate marker movement between fixes. A position that was never reported is worse than a stale one, because someone will act on it. Discrete jumps are more honest and easier on the battery
  • Visible reconnect state; full state resync on reconnect
  • Browser geolocation for "where am I" — local only, never transmitted or stored. The one exception is deliberate and per-press: see "Reporting is everyone's job" under Phase 6
  • Design at 375px first; the NCS desktop layout is the enhancement

Phase 4 — Roster / NCS panel

Two independent status axes. Do not merge them into one badge.

Operational status — manual, NCS-set with one tap:

State Aid station Sweep / SAG
pending Not yet staffed Not yet rolling
active On station Rolling
closed Torn down Finished

Radio status — automatic, derived from the feed: fresh / stale / silent, or n/a where expects_aprs is false.

So "Aid 4 — W1ABC — On station — no APRS" is a healthy row, while "Full-back — K2XYZ — Rolling — silent 18 min" should be shouting. Collapsing these loses the exact distinction that makes the panel worth watching.

Layer toggles, defaults by role:

Layer NCS Liaison Logistics
Courses, aid station locations, incidents on on on
Sweeps, SAG on on on
Aid station operators on off off

Sweeps stay on for both field roles, and for Logistics that is the whole point: the sweep is the back of the pack, so its position is what says a road segment is clear and the cones can come up. This is the same signal that lets NCS close aid stations, which is why Phase 5 (course-relative position) serves both.

Toggles persist in the browser across reconnects and phone locks.

Phase 4a — What3Words for aid stations

Each aid station POI carries an optional What3Words address. Aid stations sit at road intersections and park entrances where a street address is useless and a lat/lon is painful to read over voice; three words is the format that actually survives a radio net.

  • Maintained by NCS — entered and edited from the NCS view, read-only to the field roles, consistent with the existing role split.
  • Manual entry. No API integration, now or later. What3Words is a paid service; we are not buying it. NCS looks the address up and types it in. This also keeps club setup free of a signup and a billing relationship for a field that changes once per event.
  • Consequence: we cannot verify an address or resolve it to coordinates. Validation is shape-only (three dot-separated words) and advisory. The KML lat/lon stays authoritative; W3W is a convenience for voice traffic.
  • Displayed prominently in the aid station popup with one-tap copy, so NCS can read it out or paste it into a text message.
  • Store the words as given; validate only loosely (three dot-separated words). Do not attempt to resolve or verify offline.

Phase 3a - Course styling and draw order

Each course carries a color, an optional dash pattern and a draw order, all adjustable.

The Full, Half and 10K share road, so their lines are coincident for miles. Draw order is the control for that (course.sort_order, higher draws on top) and must be adjustable in the UI - the operator decides which route wins on shared pavement. Courses are solid by default; nothing is imposed.

Dash patterns stay available as an opt-in for the one thing draw order cannot do - showing two coincident routes at once, since a dashed line on top lets the solid line beneath show through its gaps.

Default colors are Okabe-Ito, minus the yellow which vanishes on light map tiles. Colorblind-safe matters more than usual on a course map.

Phase 5 - Course-relative position

Project each station onto the nearest course line: "Full-back is at mile 14.2". Far more actionable over a radio net than a lat/lon.

This is the operational loop of the whole event: once the full-back passes mile 6, NCS closes Aid 1-5 and roads reopen. With most aid stations dark, the sweeps and SAG are nearly the entire live picture, which makes this more valuable than its position in the phase order suggests.

Phase 6 — Incidents

Model as an incident, not a pin; the pin is just how it is drawn.

  • Bib number (identity key; may be blank initially and filled in later)
  • Location (map click, or "at Aid 4" by picking a POI)
  • Status: reported -> en route -> picked up -> closed, with timestamps
  • Short operational note; who reported, who is assigned, who closed
  • Operator initials, stored in the browser, stamped on every mutation — not authentication, just a log annotation so a shift handoff can see who did what

Status transitions are the point. A pin that is only "there" or "gone" will not survive contact with a real event; you need to see a pickup requested 8 minutes ago with nobody dispatched. Sort by time-in-current-status.

Keep medical detail out. Bib, location, status and a short operational note ("unable to continue, waiting at mile 9") are fine. Inviting narrative descriptions of a runner's condition would make this a system storing health information about identifiable people, changing both our obligations and the organizer's. The bib is the organizer's identifier — we will never have the bib-to-name mapping and should not want it.

All pin mutations go through one server-side endpoint that records who and when, regardless of role. Permission is per capability (access.ROLE_CAPABILITIES), so widening a role is a change to that table rather than to the endpoints - which is how SAG gained the pickup queue and nothing else.

Reporting is everyone's job; the queue is not (done)

Opening an incident and working one are different jobs, so they are different capabilities. CAP_INCIDENT_REPORT creates a pickup or a course note and fills in its bib, note and position; CAP_INCIDENTS moves one along its workflow and deletes it. All four roles hold the first. Only NCS and SAG hold the second.

The reason for the split rather than simply granting CAP_INCIDENTS to everyone: the pickup queue and its count are read as "who is still waiting", so the ability to close or delete an entry is the ability to make that count lie. A bearer link is only as good as the phone it is on, and Liaison's and Logistics' links are handed out most widely. Adding to the board is safe; silently emptying it is not.

The reason for granting anything at all: all four teams are out where things happen. Requiring a Logistics volunteer at an intersection to relay a pickup over the radio to whoever holds the NCS link means it arrives late, or garbled, or not at all - and that is the failure the incident board exists to prevent.

Two ways to place the pin, and the tap is the primary one. Liaison sits at the EOC with Public Safety and Medics, taking reports about places they have never seen: for them their own location is the one position that is always wrong. So "Here" is a second button beside "Drop a pin", never a replacement and never the default.

"Here" is the single exception to the geolocation privacy rule. The locate dot is local, always - never sent, never stored, invisible to other viewers. Pressing "Here" sends one fix, once, as the location of an incident the person is deliberately publishing. That is a per-press disclosure of a place rather than continuous tracking of a person, and the distinction is the whole reason it is a separate button rather than a background behaviour. If a fix comes back worse than 100 m the pin is still created - a rough position with a note beats no report - but the accuracy is stated on screen so nobody reads wifi triangulation as GPS.

Known limitation: a pin placed at a bad fix cannot be dragged to correct it. update accepts lat/lon and a reporting role may call it, but there is no UI for moving a marker yet. Today the fix is for NCS or SAG to delete it.

Key domain decisions

  • The area around the course is heard, and only matched stations are stored. Decided 2026-09-05, on the first live feed. The buddy filter was chosen to avoid storing the public's location, and it also could not hear the person on the roster when they were using someone else's radio - which turned out to be common. The resolution keeps the privacy posture and fixes the operational one: the feed asks for the roster PLUS a radius (course extent plus a mile); what the roster does not know is held in memory, shown only to NCS, and stored only once NCS says who it is. "Buddy filter, not area filter" is superseded by "area filter, seen not stored".

  • Receive-only, passcode -1. Grants read access and no transmit capability. The club needs a callsign and no secret at all.

  • Buddy filter, not area filter. The roster is known in advance, so ask APRS-IS for exactly those stations. Orders of magnitude less traffic, and it avoids incidentally storing the location of the public.

  • The roster is not the APRS feed. Three separate things: the aid station (a location from KML), the operator assigned to it (a callsign), and a position report (only if they beacon). Most aid station operators never beacon.

  • expects_aprs gates staleness alerting only. If the "who has gone quiet" panel lists 30 operators who were never going to beacon, it is noise and will be ignored within twenty minutes. It does not mean "discard their packets".

  • SSID is part of station identity. N0CALL-9 and N0CALL-7 are different radios on different people.

  • Symbol table and code travel as a pair. The table character changes the meaning of the code.

  • Store metric, display US customary. aprslib normalizes to km/h and meters; converting at ingest would make stored units depend on who ran the importer.

  • Everything is event-scoped from day one, even with a single event. One SQLite file with event_id on every table. Retrofitting multi-tenancy is painful; designing for it now costs nothing.

Known risks

  • Sparse, irregular updates. Phone apps beacon every 1-5 minutes and rural courses have cell dead zones. "Last known, 4 minutes ago" is the normal state, not an error state.
  • Igate coverage is not ours to fix but is ours to explain. A ham with no data vanishes from the map; make that legible rather than mysterious.
  • Privacy posture. These positions are already public on APRS-IS, but a page following named volunteers reads differently. The buddy filter helps: we only store people who consented by being on the roster.
  • APRS-IS etiquette. One connection, proper filter, app name and version in the login, exponential backoff with jitter on reconnect.

Resolved questions

  • How does the app know which end of a race is the start? (2026-09-29) - The club says so: Starts at / Finishes at on the Courses tab, which turns the stored line. Chosen over a Reverse button with S/F markers, which is a check someone has to remember to look at. Nothing infers direction from the file or the event's start/finish layer. A loop cannot be given a direction this way; none of the races so far is one.

  • How is a race's start time stored? (2026-09-29) - course.start_time, 'HH:MM' 24-hour in the event's zone, as typed, never converted: it is a time on the day, and the lead runners heading prints it as it stands. Not yet used for pace - a first sighting could give a pace from the start, but gun time vs. the leader crossing the mat is a question for the club before it is a number anyone acts on.

  • Where do the net's frequencies live? (2026-09-29) - event.net_info, free text edited on the event form, shown to every role under Net info. Free text over a channel table: a table has no place for "switch to the backup at mile 13" or a phone number, and the club already writes them one channel a line. At the top of the panel rather than behind a top-bar button, which is one more thing to remember is there on race morning.

  • Does the Liaison need a subset of the station roster? - Full set, with layer toggles defaulting aid station operators off.

  • Incidents across events? - No. Event-scoped only.

  • Roster size? - Under 50.

  • Public access? - None beyond the staff roles.

  • Units? - Store metric, display US customary.

  • Who maintains What3Words? - NCS, by hand. No API; it is a paid service.

  • Liaison and Logistics: one role or two? - Two. Different teams (Liaison with Public Safety/Medics, Logistics on traffic/cones/teardown), separate links so one can be revoked alone. Both read-only.

  • Does SAG need its own role? - Yes, added later. Their question is "who am I going to collect", not "where is everyone", and they were reading a view built for road clearance. The only field role that writes, and only the pickup queue.

  • Multiple NCS operators? - Yes, typically sharing one workstation. A shared role link covers it; operator initials (Phase 6) handle shift handover.

  • Overlapping courses? - Solved by adjustable draw order, not forced dashes.

  • Mobile? - Mobile-first. The field roles are the primary case.

  • Editing a roster callsign in setup: rename or bind? (2026-09-14 audit) - Rename. The Roster tab's edit is the human correcting what they typed, so the row moves and keeps its label, place, status history and any match NCS made. Binding is the opposite question - which heard station is this person - and stays NCS's tool on the live map, where it leaves the typed key alone so it is undoable. Sending the setup edit through the bind logic had been leaving two rows for one person.

Naming

The product is called Course Ops. AprsWebTracker was the working title.

Renamed throughout on 2026-09-02: repository CourseOps, Python package courseops, CLI courseops. Note that "Ops" here is the product name and has nothing to do with the Logistics team; the roles are still NCS / Liaison / Logistics.

Visual design

A colour specification is coming from the club and will replace the current placeholder palette. Two constraints must survive whatever it says, because both came from how the app is actually used rather than from taste:

  • Daylight legibility. The field roles read this on a phone outdoors. Pale or low-saturation values disappear on light map tiles.
  • Status must not rely on hue alone. Radio status and station category are currently distinguished by shape and lightness as well as colour, so they survive a washed-out screen and colour-vision deficiency.

Where colour lives today, and what a spec would touch:

Place What it controls
static/app.css :root UI tokens: ink, paper, lines, accent, and the four radio-status colours
styling.py DEFAULT_COLORS Course line palette (currently Okabe-Ito minus yellow)
static/app.css .stn--*, .poi-icon Marker fills and shapes

Lead runner tracking

Leaders per race, reported as they pass aid stations. The counterpart to the sweep: the sweep says when a station may close, the leader says when it must be ready.

Which leaders an event tracks is the club's (lead_division, one row per kind of racer, edited under Setup -> Courses). A new event is seeded with first male and first female; a club adds a wheelchair leader or a first junior and deletes what it does not award. Same three rules as the place layers and the station roles, each of which was a real failure once: seed only into an event with none, so a deleted leader stays deleted; the key never moves, because lead_sighting.division stores it and renaming must not orphan a report; and deleting one that has been sighted is refused with the count rather than leaving those reports off the panel with nothing to say where they went.

The label is stored whole - "First male", not a "male" the code prefixes - because "Wheelchair" would otherwise render "First wheelchair" and "Masters winner" could not exist. It is called a leader everywhere a human looks and a division in the code and schema, which is internal and in databases that already exist. Resolved #99.

There is no tracker on the front runner, so this is a log of reports called in over the net, not a track. Position, pace and the estimate for the next station are all derived from that log, which keeps them from disagreeing with what the net actually said.

Bib colour is pre-set per race before the event and defaults to the course line colour, but is a separate field: the line colour is a map choice, while the bib colour is how an operator identifies a runner in front of them.

Entered by NCS, not by aid stations directly. It needs to be quick - a "Passed " button, a bib box and an undo - but not one-tap, since NCS is at a workstation.

Pace outside 3:00-30:00 per mile is discarded as a clock artifact rather than shown; reports arrive in bursts and a bad ETA is worse than none.

Phase 7 - replay (dropped)

Dropped 2026-09-05, issue #2 closed. A time slider over a finished event was the one phase that produced nothing on race day, and once the report page existed the after-event questions the club actually has - how many pickups, when, where, what went wrong at which corner - were answered without it.

Nothing was removed to drop it: position (with each packet's raw line), incident_log, lead_sighting and roster_status_log keep full history, so an event is still reconstructable by hand from the database if a question ever needs it. raw_packet was retired on 2026-09-14 (audit task A4): nothing read it, and it logged position-less lines from the public before the roster check, which the area filter was accepted on the promise of never doing. The table stays defined so existing databases are untouched; nothing writes it.

Setup application and tenancy

Added after Phase 6, when it became clear the CLI-only setup contradicted the project's own premise: a club was facing a dozen terminal commands before seeing a map. /setup covers organizations, events, course import and review, aid stations, roster, access links and administrators.

Only two things stay outside the browser, because they happen before it exists:

  • the callsign in .env
  • courseops serve

The CLI is kept, not replaced - it is better for repeat or scripted setup, and it is how the test suite drives the same code paths.

Two levels, not one tab bar (2026-09-15)

The setup screen is either at the installation level (Organizations, Events, Users) or inside one event (its name as the heading, "All events" as the way back, and the nine tabs that belong to it). Configure on the Events list is the only way in. The single twelve-tab bar it replaced showed which event was open as a line of small text above the panel, and an event tab opened with no event on "Pick an event first" - the second event a club set up was where that stopped being tolerable. Considered and rejected: an event picker in the tab bar (built, looked at, discarded - still all twelve tabs, plus a dropdown that read as clutter), and per-event server routes (right for multi-tenant hosting, #5, premature for one club). The hash carries the place, #events/<slug>/<tab>, by slug because the id means nothing to a person and the slug is already permanent.

Organizations are the tenancy boundary

Added so this can be hosted for several clubs. Every event belongs to exactly one organization, and a club sees nothing of another's events, admins or race calendar.

system_admin (the host)
  +-- organization  (a club)
        +-- org_admin    runs the club's events, manages its people
        +-- event        -> event_admin, assigned events only

users.may_access_event() is the single place access is decided. It checks the organization first, before any per-event assignment, so an assignment left behind after someone changes club grants nothing. There is a test for exactly that case.

Events created before organizations existed are adopted into a default organization on upgrade, rather than being stranded and invisible.

Accounts for admins, links for volunteers

Deliberately split. Volunteers on the day keep bearer role links: a link can be re-sent to someone whose phone died, at 6am, by anyone holding it, with no account recovery and no admin awake to do it. Setup is different work - it happens beforehand, by a named person, and needs to be attributable, so it uses accounts.

Passwords use scrypt from the standard library. Each hash carries its own salt and cost parameters, so the cost can be raised later without invalidating existing passwords. Sessions live in the database so they can actually be revoked - on logout, password change, or deactivation.

The course review became a screen

Phase 2's plan called for a review screen; it was first built as a CLI listing, which was exactly the friction the plan was trying to avoid. Staged features are now drawn on a map, with list and map selection in sync, because organizer files are wrong in ways a list of names cannot reveal.

Pin labels (done)

Pins are labelled with one or two characters derived from the place name, so a glance answers "which one is that?".

Decisions taken, so they are not re-litigated:

  • Characters on the pin, not names beside it. Names collide into a smear at the whole-course zoom, which is the zoom NCS actually sits at. Names are available as an opt-in switch, gated on zoom, and default off.
  • Derivation is a guess and is never stored. Storing it would freeze the label when the place is renamed. poi.label is an override only, and is cleared when it agrees with the guess.
  • A number in the name beats a letter. "Aid 1/2/3" is at least as common as "Alpha/Bravo/Charlie", and first-letter derivation labels the whole course "A". Words describing the kind of place ("aid", "water", "stop") and the race ("ALL", "FULL", "half") are skipped, because the real Mankato export is full of WATER (ALL).
  • Two characters is the budget. A marker is 24px; a labelled one is 30px. Three characters do not fit, and text that does not fit is worse than none.
  • A labelled pin loses its glyph. They cannot both fit. This is why labels are per layer: in practice one layer is labelled and the rest keep glyphs.
  • Collisions are surfaced, not solved. Six places named WATER (ALL) all derive W, because their names are genuinely identical. The Places table shows each label so a club can see that and rename or override. Inventing distinct labels would put characters on the map that match nothing anyone says on the radio.

Ordering places (done)

Geometry orders places by distance along the nearest course. That works for one route and breaks for more than one: where routes share pavement the snap is a coin flip, so the list interleaves miles measured on different races.

  • The club's order wins. poi.sort_order, set by dragging in setup.
  • 0 means unplaced and sorts last, by distance. An event nobody has ordered is unchanged, and a later import lands at the end rather than the middle.
  • Both the Places table and the lead runner progression use it, or "the next station" would disagree with the list the club just arranged.

Which race a stop serves is now stated, not guessed (poi_course). A stop serves any number of races, because one water stop routinely serves several - the organizer's file says so, naming them "WATER (ALL)". A place with nothing stated falls back to the proximity snap, which keeps existing events working; that fallback picks exactly one race and is what made a progression skip stops.

Live tracking is a switch, and off by default (done)

Turning the APRS-IS feed on was a systemd edit. It is now a switch in /setup -> Tracking, because the person responsible for the net is not necessarily the person with a shell on the server, and this is something switched on the morning of an event and off again afterwards.

Decisions taken:

  • Off by default, for privacy rather than cost. The buddy filter asks for each operator's callsign wherever they are, not only on the course. A feed left running records where volunteers live and work, which is the same concern that made this a buddy filter rather than an area filter.
  • Persisted on the event, not held in memory. A deploy restarts the service; a feed that failed to come back mid-event looks exactly like a quiet net, and nobody goes looking.
  • Still exactly one connection. Enabling one feed stops any other, and enabling a running one is a no-op, so a double press cannot double the connections.
  • Refuse rather than fail silently. No usable callsign means the switch will not move, with the message from settings that says what to put in .env. A switch reading "on" with nothing behind it is worse than no switch.
  • The filter is shown. An empty or wrong filter is the commonest silent failure here: the feed connects, nothing matches, and the map stays blank while everything reports healthy.

Never exercised against real traffic. Every run so far has been with the feed off. This is the largest untested claim in the project.

Live verification

The feed was turned on for real on 2026-09-05, race-week check-in for the 2026-10-17 Mankato Marathon, with five callsigns on the roster. Observed on the NCS view on an iPad (Chrome on iOS):

  • APRS-IS connected receive-only as WX0MIK; packets from rostered stations were stored and drawn.
  • The area filter (course extent plus a mile) delivered stations the roster did not know; "Needs attention" listed them; matching one to a roster entry worked and its positions were stored from then on.
  • "Here" placed a pickup at the phone's own location.
  • The tracking switch survived four deploys in one day.
  • Returning to the app from the background no longer scrolls the header away.

First real event, 2026-09-27 (a bike ride, 2026katobike, six on the roster). The feed ran all day on the production server with no reconnects, and NCS matched stations to roster entries. A phone that stopped sending showed its age climbing in red (4h44). The server log proved the cause was the phone app's send switches, not the app (runbook, "One station's age keeps climbing"). Afterwards the event was archived (#4, built that night).

Decided 2026-09-27: no pre-event beacon check-in for this club. Runbook step 6a suggests one at a club meeting a week out, but most of the marathon's operators are not local enough to attend. The race-morning safeguards stand in for it: Needs attention surfaces a wrong SSID on the first beacon, a bare roster callsign binds itself, and NCS glances at each operator's row as they check in on the net. A row reading "never" or an old age gets one question: is the app's APRS-IS switch on?

Exercised only in tests or on a scratch event, not yet reported from a live feed: a rostered station going stale/silent; the pickup workflow across two devices; lead runner sightings; a phone through a dead zone; the after-event report with real notes; GPX from a real MapMyRun export. The club may have tried some of these without saying so - ask before assuming untested.

End-user documentation (decided 2026-09-09/10, moved in-app 2026-09-13)

The guides volunteers read live in src/courseops/guides/ and are served by the app itself at /help/<page> (guides.py). From 2026-09-10 to 2026-09-13 they were published to the GitHub Wiki by a workflow; that put the one thing a volunteer reads on race morning on another site, in another look, and a club running its own copy was either sending people to our wiki or maintaining a fork of it. Now every running copy carries the guides for the version it is running.

  • Still Markdown, still reviewed. The pages are files in the package, so GitHub renders them in the repository, they diff in a pull request, and a screenshot is a file beside them. A change to a guide is a change to the app and deploys with it.
  • Rendered by our own converter, not a dependency. The pages use a dozen constructs; guides.render handles exactly those and leaves anything else as visible text rather than dropping it. A Markdown library would be a fifth runtime dependency for the sake of nothing on these pages. tests/test_guides.py renders every shipped page and resolves every link and image in it.
  • Unauthenticated. The guides are public in the repository already, name no event and carry no token; the ? has to open without asking anyone for anything. A page name is a bare slug or a 404.
  • The GitHub Wiki is retired. tools/build_wiki.py and .github/workflows/wiki.yml are gone. The wiki itself should be switched off in the repository settings so a stale copy does not outlive the source (a manual step; it is not in this repository).
  • Eleven pages: one per role, a shared "basics", and the setup guide split into four (course / roster and links / race week / making it yours). The split was by when it is read, not by length - "making it yours" is read weeks before the rest, while a club is deciding whether this fits them.
  • Screenshots come from a demonstration event seeded with invented callsigns, operators and bibs on a synthetic course, with a fictional club callsign passed as an environment variable. No organizer file and no captured traffic is involved, which keeps the public-repo rule intact. tools/seed_demo.py builds it (checked in 2026-09-12, after the design pass re-shot all 32 images), so a re-shoot is a script run rather than an afternoon.
  • In-app help: a ? in the top bar of every screen opens the guide for the link that person is holding, at /help/<role> on the same server, in a new tab. The setup screens carry the same ring, following the tab.

Several people on one role (decided 2026-09-09, confirmed as a requirement)

One access token works on any number of devices, and the hub fans every change out to all of them - three Net Control operators on three screens is a supported way to run a big net, not a workaround.

A link per operator is therefore not about access, it is about revocation and trust: it is what lets one phone left in a parking lot be cut off without taking the others off the air, and what lets two net controllers know which link is whose. Links carry a free-text label for that reason, and per-link revoke is the ordinary action while "replace all" is for a compromised role.

Last write wins throughout; there is no locking and no "someone else is editing" indicator. roster_status_log records who set what, from the Operator box. The one real hazard found - a socket-driven re-render eating a half-typed bib - is fixed (captureFieldEdit in app.js).

Known gaps and open threads

  • The what3words coordinate URL is undocumented. It broke, as this note said it might, and the links were removed 2026-09-05. The Places table still shows each place's coordinates, which are ours and cannot break, now with a copy button: paste them into the what3words search to read the words off. Going the other way is safe: what3words.com/a.b.c is the documented share form, so a place WITH words gets a link to its square (2026-09-06). The manual What3Words field stays; the paid API stays out.

Things discovered but not yet acted on. Each is a real constraint, not a wish.

  • A stop more than 250 m from a race's line has no mile on that race. (2026-09-29, Mankato.) Water stop A serves the Half, but the organizer's Half line never comes within 284 m of it. NOT a drawing error: the organizer (a professional job) deliberately spaced the lines apart so each race is visible on the map where they share road. So do not ask them for a "corrected" file - the offset is the design, and the line is a picture of the route, not the road's centreline. The Half leader can still be reported at A; there is simply no pace or ETA for the Half's first leg until E and G are reported. RESOLVED 2026-10-01: a stop ticked for a race (poi_course) reaches progress.STATED_MAX_OFFSET_M (500 m) on that race, because the club has already said it is on that route; everything else keeps DEFAULT_MAX_OFFSET_M (250 m), which is what stops a stop on a neighbouring road getting a confident wrong mile. The mile is as good as the offset line.

  • A loop cannot be given a direction by its start and finish. Starts at / Finishes at refuse a line whose ends are within 150 m. None of the races so far is a loop; a club with one would need a Reverse control.

  • Checking miles against the real organizer files. They are never in the repo (third-party data). Import them into a scratch database, set the start, tick the stops as in live setup, and read CourseIndex.place_positions / progression per race: each race's stops must read in rising order down the club's order. That is how the stop I double-pass (Full 16.3 vs 20.6) was found; a synthetic fixture would not have had it.

  • GPX import - done 2026-09-05, issue #1. gpx.py reads tracks, routes and waypoints into the same features KML produces; dispatch is on the root element, so the extension does not matter. Point density is warned about, never thinned - simplification stays a separate decision.

  • Geolocation requires HTTPS. Resolved by Phase 8: Apache terminates TLS with a Let's Encrypt certificate, so the field roles' location dot works. A club running on a LAN without TLS still loses it.

  • OpenStreetMap tile policy — issue #3. Fine for one club; not for a hosted multi-club service.

  • No way to archive a finished event off the live server — issue #4, built 2026-09-27 after the first real event. Archive on the Events list ends an event in place: both tracking switches off, the event's positions deleted, role links answer 404 (access.resolve checks event.archived_at), and the list hides it behind Show archived. Unarchive restores the same links with tracking still off. Archiving in place rather than export-then-delete was the user's choice: the record (report, incidents, notes, roster) stays viewable in setup, and what #4 was about - where volunteers are - is gone either way. The planned file export shipped alongside it: Download / courseops export writes one event to a SQLite file in the same schema without accounts, links or positions, and courseops serve --db FILE reads it. No importer, no id remapping. admin.EXPORTED_TABLES and NOT_EXPORTED must between them name every event-scoped table; a test fails otherwise. Organization-level export and restore stays with #5.

  • Multi-tenant rough edges — issue #5. Resource limits, a signup path with password reset, and static asset caching.

  • An open page is not told a new version is running. Issue #112, closed 2026-09-10: the setup screen carries a "New version - reload" pill, and no other page does. The field roles cannot act on it, and a prompt nobody can act on is noise on the one day noise costs something. Original note: A deploy replaces the code on the server, not the page a volunteer has had open for hours, so after a mid-event deploy phones run the old app.js against the new API with nothing on screen to say so. Setup has a version chip; the field app, held by the people who will never think to reload, has nothing. The fix must never auto-reload - yanking the map out from under somebody mid-net is worse than slightly stale code - and must not compete with the connection badge. Not urgent: the discipline is not to deploy during a race. It exists for the deploy that happens anyway.

  • Lead runner divisions are fixed at male and female. Issue #99, found 2026-09-10 while writing the setup guide, closed in 0.7.0: which leaders an event tracks is now per-event data in lead_division, edited under Setup -> Courses, exactly as the place layers and the station roles already were. See "Lead runner tracking" above.

  • Hand-drawn courses cut corners. Decided 2026-09-05: accepted, not a gap. The Mankato export has 13 straight-line gaps over 200 m (largest 1241 m) where the route builder used direct/offroad mode, so the line is shorter than the road and Phase 5 mile figures drift on such files. The club's position is that the course line exists to see the route; the authoritative distance information is the mile-marker places the organizer supplies, and those are exact regardless of how the line was drawn. So nothing will smooth, snap or re-measure a course, and a snapped mile figure is a convenience with the course name beside it, never the number someone plans around. test_real_course.py still asserts the shape of the file so a change in the organizer's export is noticed.

  • Course files carry no aid stations. True of the MapMyRun export and likely of others, so aid station locations and their What3Words addresses are a manual step regardless of file format. A test asserts this so we notice if a future file does include them.

  • Point density. A recorded GPX can carry thousands of points where a GIS-drawn KML carries dozens. Simplification is a separate decision; import must not silently discard fidelity.

  • The event time zone is stored but nothing reads it yet. The report page (issue #7, 2026-09-05) reads it: times are formatted by the browser in that zone. Everything below still holds for anything server-rendered.

  • The event time zone was stored before anything read it. event.timezone is set in the UI and saved, but every timestamp is stored UTC and displayed as a relative age ("8 minutes ago"), which needs no zone. It becomes load-bearing the moment anything shows a clock time - a start time, a lead-runner ETA as wall clock. Capturing it now means those do not have to guess, and means an event is not silently assumed to be in the browser's zone - NCS may be running the net from another state.

  • Binding picks one SSID and keeps it. A bare-callsign roster entry binds to the first non-infrastructure SSID heard and does not re-bind on its own, because a marker that moves between two radios mid-event is worse than one that is merely on the wrong radio. Someone who genuinely switches rigs needs NCS to press "This is " on the new SSID, which rebinds. There is no UI for unbinding without a new SSID to point at; db.unbind_station exists for when that turns out to be needed.

  • Non-ham volunteers cannot be tracked. Built 2026-09-15, issue #6; the design and its reasoning are in docs/phone-tracking.md. A roster entry can be tracked by phone app: a designator (M1) instead of a callsign, and a tracking app on the person's own phone (OwnTracks by QR, Traccar Client by typed URL) posting to one per-event URL. The roster is the allowlist, designators are normalised on both sides, the unknown ones go to Net Control's nearby list, and the reported timestamp - never arrival - is what is stored, because both apps buffer through dead zones. Verified on a real iPhone 2026-09-15: the first scan found OwnTracks refusing the config link until Settings → Remote Control → Allow external configuration is on (now a step on the card, with the app's own screens in /help/phone-tracking); with it on, the scan configured the app and a fix reached the live map within a minute. Android not yet tried - the setting is documented as identical; the first event with an Android medic rehearses it a week out.

  • Course notes have no export - done 2026-09-05, issue #7. The report page (report.py) is one printable page for the race lead: pickup counts by stop and window, and every course note with a small map, no names.

  • Place types were a fixed list. Resolved: poi_category is per-event data with no limit, and staffed replaced the hardcoded aid_station check. The remaining constraint is display, not storage: past a handful of layers, icon-plus-colour stops being reliably distinguishable outdoors and with colour deficiency. That is a reason to choose defaults well, never a reason to cap the count - a club with a mile marker layer of 26 pins wants it switched off by default, which the visible flag covers.

  • Two teams, two alphabets: should the app disambiguate? - No. Medical and the water stops are numbered independently, so the medical team's Juliet and the water stops' Juliet are different corners, and a bare "Juliet" on the net means two things. The app could prefix names with their layer automatically, but then the screen would say "Water Stops Juliet" while the radio says "Juliet" - inventing a second vocabulary. Decided instead that the club names the points explicitly ("Ham Alpha", "Medic Alpha"), so the map says exactly what people say. The place-rename table shows each point's layer while you type, which is what makes that practical.

  • We have not seen a real organizer's points file yet. Received, and it answered the question in an unexpected way: the points arrive in one flat folder, but the exporter's Type attribute names the layer for every point, so they effectively arrive pre-sorted after all - just not as folders. Import now creates the layers and files each point. The bulk-move work is still needed as the fallback for files that carry no such attribute. The one real export we have (Mankato, MapMyRun) is entirely flat - no <Folder> elements - so places import into a single layer and are sorted afterwards in the Places table. If a club's file does turn out to use folders, seeding layers from the folder names would be a small, obvious improvement. Nothing depends on either shape; test_real_course.py pins what we know.

  • Colour specification pending from the club; see Visual design above.

  • Static asset caching. Tracked in issue #5.

  • Operator runbook is a draft. docs/RUNBOOK.md exists and covers what is built. Sections marked [CLUB] need the club's own practice filled in (who runs the pre-event check, how links are distributed, where backups go), and [PHASE n] sections fill in as those phases land.

Hand-placed unit pins (#196, built 2026-10-19)

Police, CSOs and Public Works: a roster entry with tracked_by = 'manual', the badge number as its key, a pill marker, moved by Net Control and Liaison (CAP_UNIT_MOVE), seen by every field role but Staff (CAP_UNIT_VIEW). The design is the 2026-10-02 comment on the issue, with one change: units are created and removed on the live pages (Liaison and Net Control hold no setup account and do not know who is working until the day), so setup is optional. Not built: a place-in-advance bulk entry (units are placed one at a time), and a trail (a hand-placed unit keeps one position, as every station does).