API / Backend

SwimSpace API

Hand it a swim — "100 Free", 1:02.34 — and it tells you what that time already qualifies for, and exactly how much you have left to chase the next cut.

The Idea

Qualifying standards started out hardcoded inside the SwimSpace app. Every new LSC meant editing the same data in about five different places in Swift — the standard loader, the lookup service, and the region lists in both the times and goals screens. That was fine for one state. It was never going to scale to fifty-nine.

So I pulled the cuts out of the app entirely and into their own service. It's the least glamorous problem in SwimSpace, and also the one that actually decides whether the app is useful to a swimmer outside Illinois. The database now covers nine LSCs — USA, Illinois, Indiana, Iowa, Wisconsin, Hawaii, Pacific, San Diego-Imperial, and Florida Gold Coast — and I'm adding more as I get to their standards.

What I Built

The endpoint I actually built this for is /v1/check. Give it an LSC, a swimmer's age and gender, an event, and a time, and instead of dumping a table of cuts back at you, it grades the swim: every standard that time already clears, and the next one up with the exact gap left to close. Clear everything your home LSC publishes and it keeps going — into Zone Sectionals, then Futures — instead of just stopping at "qualified."

Everything else is in service of that one endpoint: /v1/lscs lists what's covered, /v1/standards returns a full table for an LSC/age/gender/course, and /v1/compare lines up standards across LSCs side by side. It's all built on FastAPI, with a Python pipeline behind it that turns each LSC's published PDF into the validated JSON the API actually serves.

"The boring data problem was the whole product."

Process

I assumed I'd write one parser and point it at every LSC's PDF. That lasted about a day. Every LSC lays its standards out differently — different tiers, different column layouts, different ways of grouping ages — so there's no single format to target. I ended up writing a separate extractor per LSC instead — extract_iowa.py, extract_hawaii.py, extract_wisconsin_lc.py, and so on — each one adapted to how that LSC happens to publish its numbers.

Some of those PDFs don't even have a text layer — they're scanned images, so a normal parser gets nothing back. Those need OCR first: rendering the page to an image and running it through Tesseract before any text extraction can start. And the PDFs themselves publish age groups ("11-12"), while the app needs a standard for every single age, so the pipeline expands each group out to individual ages 6 through 18. A validation step, build_cuts.py, checks every one of those specs and merges them into cuts.json — which has to come out byte-identical across three places: the iOS app bundle, the website, and this API. Keeping those three in sync without drifting apart is its own constraint I had to design around.

What I Learned

Building the app, a bug is usually obvious — something looks wrong on screen and I fix it. Bad data here is quiet. If an extractor misreads one number, a swimmer just sees the wrong cut and has no reason to doubt it. That's the real reason build_cuts.py validates every spec instead of trusting whatever the parser handed back.

FastAPI itself was the easy part — I'm not going to pretend otherwise. What actually took the time was that this data is published for a person to read off a PDF, not for a program to parse, and every LSC does it a little differently. And because I'm the swimmer this is for, I know what I'd actually want to ask a cuts table — not "show me every standard," but "what does this time mean, and what's next" — which is why /v1/check exists instead of just a bigger data dump.

It pairs with the SwimSpace app — see my other work or read more about me.

Done exploring?
Back to the work.
Return to Portfolio