Behind the scenes
How it's built
One program on the meet director's laptop. Every phone is a browser pointed at it. The playbook is data, a meet is one file, and everything a volunteer sees or prints is computed from those two things. This page is for anyone who wants to know how; the first screen is the short version.
Architecture
A laptop, some phones, and a Wi-Fi network
- One Python process (Flask), started from a shortcut
- Meets and playbooks as JSON files in a
datafolder - Computes the schedule, every role's tasks and every page on each request
- Reads the heat sheet, draws How it Works, renders the guides
- A browser, opened by scanning a QR code
- A cookie says which role this phone is; the server shows only that role's pages
- Asks "has the meet changed?" every 15 seconds; the heat board every 4
- Nothing installed, nothing stored
No database server, no accounts, no internet. Athletes' names never leave the laptop. The internet is used for two things only: the weather forecast on Home, and the timing company's live results on Awards.
The model
Playbook, meet, tasks, phones
A task, as the playbook stores it
Nothing about a role is written into the code. This is the 1st call as it lives in the playbook: a template for the label, when it happens relative to the event, who does it, who else may, who sees it, and how it gets done. Every task around every track event is one of these.
# meet_planner/defaults.py {"id": "call1", "label": "{event} 1st call", "notes": "Athletes to the clerking table", "ann_notes": "1st Call, {event_call}", "when": {"anchor": "call1"}, "owner": "announcer", "also_by": ["admin"], "watchers": ["*"], "mode": "tick", "core": True}
What it becomes on meet day
For the Girls 400m, 4 heats, expected at 9:44, with the playbook's calls at 30, 15 and 10 minutes before:
| Time | Task | Done by | Shown to |
|---|---|---|---|
| 9:14 | Girls 400m 1st call | Announcer | Everyone |
| 9:29 | Girls 400m 2nd call | Announcer | Everyone |
| 9:29 | Girls 400m: putting heats together | Clerk | Clerk |
| 9:29 | Girls 400m: hip numbers and send heats | Clerk Helper | Clerk Helper |
| 9:34 | Girls 400m 3rd call | Announcer | Everyone |
| 9:42 | Girls 400m: all heats sent? | Clerk Helper | Clerk Helper |
| 9:44 | Girls 400m begins | Meet Administrator | Everyone |
Add a role to the playbook, or a task around every race, and this table, every role's page, the printed guide and the How it Works drawing all change. There is no second place to update.
The schedule engine
Planned times, live times, and what moves them
A track event lasts its heats times the playbook's seconds per heat (a 400m heat is 125 seconds; a 1600m heat is 8 minutes). Field flights have their own minutes. Awards take a slot at championship meets. The engine walks the running order once and gives every item two start times: planned, from the meet's start time and the durations, and live, which is the same until the day starts to happen.
Three things move the live times:
- A real start. When a race is started, its actual time replaces the estimate, and everything after it is recomputed from there.
- A delay. "+15" pushes the first race not yet started at or after that moment, and so everything after it, on the track and in the field. A race that already started isn't moved; its real start already reflects any delay before it.
- Hold. With the playbook's never start an event earlier than planned on, live times are
max(live, planned): a meet running early doesn't call athletes before their published time.
Calls, hype songs, awards presentations and the announcer's script all hang off events by an anchor and an offset, so they move too, without being stored anywhere.
Worked example: the Boys 1600m goes off six minutes late
| Item | Planned | Live | |
|---|---|---|---|
| Girls 1600m · 2 heats | 8:15 | 8:15 | started on time |
| Boys 1600m · 2 heats | 8:31 | 8:37 | Started Now tapped at 8:37 |
| Girls 100m Hurdles 1st call | 8:17 | 8:23 | 30 min before the event |
| Girls 100m Hurdles · 4 heats | 8:47 | 8:53 | 8:37 + 16 min |
| Boys 100m Hurdles · 2 heats | 8:53 | 8:59 | 8:53 + 6 min |
# meet_planner/schedule.py, the heart of it actual = parse_time(ev["progress"]["started"]) if ev else None if actual is not None: start = actual pending = [d for d in pending if d["at"] > start] else: start = max(live_t, planned) if hold else live_t while pending and pending[0]["at"] <= start: start += pending.pop(0)["minutes"] live_t = start + minutes + gap
The report at the end of the day compares the two columns for every race, and turns the real minutes per heat into a suggested change to the playbook's timing.
Tasks
One owner each, and nothing falls between two people
Every task has exactly one owner, the role that does it. Other roles can watch it: the calls are shown to everyone, so the clerk's phone says "1st call made" the moment the announcer checks it off. A task gets done in one of three ways:
- Tick. Someone checks it off. The announcer's calls, the script, the hype song.
- Fact. It's done when something happens. "All heats sent?" is done when the last heat is sent; "begins" is done when the race is started.
- Tick or fact. Either. "Putting heats together" can be checked off, or it counts as done when the first heat goes to the start.
Starting a race implies its calls, forming and sending, and closes everything about the races before it. That's why nobody checks off the same thing twice, and why a late check-off doesn't leave a phantom "not done" on someone's list.
When a role isn't at a meet (no announcer at a regular meet, no clerk helper today), each of its tasks follows the playbook's rule for that role: pass to another role, switch off, or ask. A task nobody owns is flagged on Setup with one-tap fixes. It can't quietly disappear.
# meet_planner/roles.py def resolve_owner(owner, absent, roles, present): """Who does an action at this meet: (role, status).""" if owner in present: return owner, "ok" if absent == "off": return None, "off" seen = {owner} rule = roles[owner].get("fallback") while rule not in (None, "ask", "off") and rule not in seen: if rule in present: return rule, "inherited" seen.add(rule) rule = roles[rule].get("fallback") return None, "off" if rule == "off" else "unowned"
| Role | If not at the meet |
|---|---|
| Announcer | Falls back to the Meet Administrator (the default at regular meets) |
| Clerk, Clerk Helper | Ask: flagged on Setup for the director to decide |
| Starter, helpers, field crew, Coach | Their tasks switch off |
The heat sheet
Reading it, and reading it again on the morning of the meet
The Athletic.net meet program is a two-column PDF. pdfplumber gives the words on each page with
their positions; the parser crops each column, groups words into lines by their vertical position, and
walks the lines with three patterns: an event header (#3 Girls 100 Meters), a heat or flight
header (Heat 1 of 9 Prelims), and an entry. Grade and school sit at a fixed horizontal offset
from the place number, which is how a missing grade is told apart from part of a long name. Events with
prelims get a final added, with the number advancing read from the sheet.
Entries change. A re-import parses the new sheet and matches its events to the meet's by number and name, then shows what differs in each one, in words: heats and athlete counts, who was added or removed, hip numbers added, an event renumbered. The director picks which changes to apply. The running order, the times, the script and the check-ins already made are kept, and an event that vanished from the sheet is reported, never deleted. Every re-import writes a backup first.
# meet_planner/heatsheet.py EVENT_RE = re.compile(r"^#(\d+)\s+(Girls|Boys|Women|Men|Mixed)\s+(.+?)\s*$") UNIT_RE = re.compile(r"^(Heat|Flight|Section)\s+(\d+)\s+of\s+(\d+)" r"\s*(Prelims|Finals|Semis|Semifinals)?") for page in pdf.pages: mid = page.width / 2 for x0, x1 in ((0, mid), (mid, page.width)): col = page.crop((x0, 85, x1, page.height - 40)) for line in _lines(col.extract_words()): text = " ".join(w["text"] for w in line) if m := EVENT_RE.match(text): # a new event ... if m := UNIT_RE.match(text): # a new heat or flight ... if entry := _parse_entry(line, current.kind): unit.entries.append(entry) # meet_planner/reimport.py: what the director sees "Heats: 4, 4, 4 → 4, 4, 4, 3 (12 → 15 athletes)" "Added (3): Whitlock, Leona (Pine Haven) · Holloway, Inez (Kestrel) · …" "Hip numbers added"
Live updates
Why every phone polls, and why that's enough
Each meet has a version number that changes whenever anything about it is saved. A page asks for it every 15 seconds and reloads if it changed, unless someone is typing. The heat board asks every 4 seconds and redraws in place, so two clerks on two phones see each other's taps almost immediately.
Websockets would be faster. They would also mean a long-lived connection per phone, a different server setup, and something new to go wrong on a laptop on an infield. Fifty phones polling every 15 seconds is about four requests a second, each answering with a dozen bytes. The simplest thing works, and it keeps working when a phone drops off the Wi-Fi and comes back.
Writes go through one method. store.update re-reads the meet, applies one change, and saves,
all under a lock, so two people tapping in the same second both get saved. The meet is one JSON file;
on Windows a file can't be replaced while it's being read, so reads wait for writes too.
// static/app.js const check = async () => { try { const v = (await (await fetch(el.dataset.versionUrl, { cache: "no-store" })).json()).v; if (version === null) version = v; else if (v !== version && !typing) location.reload(); } catch { /* offline: try again next time */ } }; setInterval(check, 15000); # meet_planner/store.py def update(self, meet_id, change): """Read the meet, apply change(meet), save it, all under one lock.""" with self._lock: meet = self.load(meet_id) result = change(meet) self.save(meet) return result
Paper
Everything printed is generated from the playbook
Role guides and the handbook
The playbook keeps each guide's text with placeholders
({arrive}, {clerk_meeting}, {staffing}). For a meet they become times
and the schools supplying the role; for the handbook they become rules ("45 minutes before the first race").
Two sections are never written by hand: the role's tasks, from the playbook's tasks, and where it stands, from
the stations. So a guide can't disagree with what the app asks.
How it Works
An SVG drawn by the server from the playbook's stations: the path athletes take from the clerking area to the finish, who works each spot and what they say, with the viewer's own station highlighted. On meet day it reads the heat board, so the clerking area shows how many are here and the cones show which heat is waiting. A meet with no clerk helper redraws with the clerk covering that spot.
PDFs and the Run of Show
The guide pages are Markdown, rendered in the app under Help.
The PDFs are the same pages printed through headless Edge by a script; the screenshots in them are retaken by
another script from the demo meets. The Run of Show is a spreadsheet (openpyxl); the QR codes are
generated per role and per school, pointing at the laptop's address.
Quality
Tested like it has to work on a Saturday morning
105 automated tests, from parsing a heat sheet to which role can open which page, two clerks ticking the same heat, and the schedule after a late start and a delay. The documentation is tested too, because a guide that names a page that no longer exists is a bug a volunteer will find on meet day:
# tests/test_meet_planner.py def test_every_help_page_renders_for_every_role(client): ... def test_guide_links_anchors_and_screenshots_all_resolve(): ... def test_guide_names_only_pages_and_roles_that_exist(): ... def test_role_guides_and_staffing_are_filled_in_for_the_meet(client): ...
Every screenshot on this site and in the guide is taken by a script from two made-up meets, at a fixed moment in a demo morning. That's possible because the app never asks the computer what time it is directly: one module answers "now", and an environment variable can set it to any date and time, still ticking.
# meet_planner/clock.py """Set FIRST_CALL_CLOCK to a date and time ("2027-05-29 09:05") before starting the app and every page behaves as if it were that moment, with the clock still ticking from there.""" def now() -> datetime: offset = _offset() return datetime.now() + offset if offset is not None else datetime.now()
The same seam is what will let a hosted server run in UTC while each meet keeps its local time.
Built with
Small, boring, dependable parts
| Part | What it does |
|---|---|
| Python and Flask | The server on the laptop: about 7,300 lines of Python in 30 modules, each one thing (the schedule, the tasks, the heat sheet, the guides, the report). |
| Jinja templates, plain CSS and JavaScript | Every page, with no front-end framework and about 1,000 lines of script. Phones get a bottom tab bar; laptops get the numbered menu. |
| pdfplumber | Reads the Athletic.net heat sheet. |
| openpyxl, qrcode | The Run of Show as a spreadsheet; the QR codes for every role and school. |
| JSON files | Meets and playbooks, one file each. Deleted meets go to a trash folder; every re-import and bulk change keeps a backup. |
| pytest, headless Edge | The tests; the screenshots and PDFs for the guide. |
Where it stands
What a laptop on an infield doesn't need yet, and what comes next
First Call is built for a trusted room: one laptop, a network the director controls, phones held by volunteers who were handed a card. Some things a public web app needs, it deliberately doesn't have today.
- Roles aren't passwords. A phone's role is a cookie set by scanning a code; anyone on the Wi-Fi who knows the address can pick a role. The guide says so, and says to close phone access when the meet ends.
- It runs on Flask's development server, with no CSRF tokens and no rate limits. Fine behind a hotspot; not fine on the internet.
- The write lock lives in one process. Two server processes would need a database transaction instead.
The plan, in three steps that are each useful on their own
- CodesEach meet protected, each role entered by its code or QR card. Still no logins for volunteers. Works on the laptop first.
- HostedFirst Call on a server, so coaches in the stands can follow on cellular from anywhere. The meet stays one document; the schedule engine, tasks and pages don't change, only where the document is kept.
- OrganizationsEach division or timing company its own space, with its own playbooks, meets and people.
The laptop version stays. No internet, no monthly server and no student data leaving the venue is the right answer for a lot of meets.