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

The laptop
  • One Python process (Flask), started from a shortcut
  • Meets and playbooks as JSON files in a data folder
  • Computes the schedule, every role's tasks and every page on each request
  • Reads the heat sheet, draws How it Works, renders the guides
Every phone
  • 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

The playbookRoles, stations, guides, the tasks around each kind of event, timing, the timeline and script. Set once.
A meetEvents, heats and athletes from the heat sheet; the date, start times, who's working, and what has happened so far.
Timed tasksEvery call, heat and start for this meet, each with a time, one role that does it, and the roles it's shown to.
Every phoneEach role's pages, guide and How it Works, drawn from the same tasks.

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:

TimeTaskDone byShown to
9:14Girls 400m 1st callAnnouncerEveryone
9:29Girls 400m 2nd callAnnouncerEveryone
9:29Girls 400m: putting heats togetherClerkClerk
9:29Girls 400m: hip numbers and send heatsClerk HelperClerk Helper
9:34Girls 400m 3rd callAnnouncerEveryone
9:42Girls 400m: all heats sent?Clerk HelperClerk Helper
9:44Girls 400m beginsMeet AdministratorEveryone

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

ItemPlannedLive
Girls 1600m · 2 heats8:158:15started on time
Boys 1600m · 2 heats8:318:37Started Now tapped at 8:37
Girls 100m Hurdles 1st call8:178:2330 min before the event
Girls 100m Hurdles · 4 heats8:478:538:37 + 16 min
Boys 100m Hurdles · 2 heats8:538:598: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"
RoleIf not at the meet
AnnouncerFalls back to the Meet Administrator (the default at regular meets)
Clerk, Clerk HelperAsk: flagged on Setup for the director to decide
Starter, helpers, field crew, CoachTheir 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

PartWhat it does
Python and FlaskThe 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 JavaScriptEvery page, with no front-end framework and about 1,000 lines of script. Phones get a bottom tab bar; laptops get the numbered menu.
pdfplumberReads the Athletic.net heat sheet.
openpyxl, qrcodeThe Run of Show as a spreadsheet; the QR codes for every role and school.
JSON filesMeets and playbooks, one file each. Deleted meets go to a trash folder; every re-import and bulk change keeps a backup.
pytest, headless EdgeThe 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

  1. CodesEach meet protected, each role entered by its code or QR card. Still no logins for volunteers. Works on the laptop first.
  2. 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.
  3. 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.