Golf generates a preposterous amount of data and then loses almost all of it.
There’s the handicap service, which knows your scores and computes one number. There’s whatever you scribbled on the card, which is in a drawer. And there’s memory, which is a liar — ask me which hole is quietly costing me strokes and I’ll confidently name the one where I made a double last Tuesday.
The official app shows you the number. It will not tell you why the number is what it is. So I built the surface that does.
The app I’d already built, disassembled across three machines
The unusual part isn’t the app. It’s how it got chosen.
Instead of picking the next thing to build by vibes, I ran an evidence sweep over my own data exhaust — the archived AI transcript corpus, shell history, the vault, live task and list contents, bookmarks, the existing apps’ source. Eleven sources. Then I had the result graded by a reviewer briefed to kill every candidate and verify every citation. Nine candidates died. Sixteen of sixteen spot-checked citations held.
The verdict was blunt: I had been building a golf app by accident for ten months.
The components already existed, they were just scattered:
- Ingestion was an iMessage shorthand I’d invented eight days earlier — text
sgfollowed by distance, club, lie, carry, proximity, and up-and-down yes/no, and a script on the always-on Mac parsed it. - The database was a JSON file with five shots in it.
- The handicap module was a reverse-engineered API against the national handicap service.
- The tee sheet was a Playwright scraper.
- The practice log was an empty folder.
- Version one was an archived repo.
My favorite finding, the one that made the decision for me: that JSON file was living inside the Docker volume of my workout app — readable only with sudo — and the workout app’s own written rules say do not log golf here, lifting only.
I had built an app inside an app that explicitly rejects it, on a machine my main agent didn’t even index. That’s not a workaround. That’s an unregistered birth.
Conventions don’t hold behavior. Surfaces do.
The sweep turned up a pattern I’ve now seen too many times to argue with.
Ten days before, I’d designed a practice-log convention: one dated note per session, in a folder made for the purpose. Ten days later the folder had zero files in it. The drill library beside it had four sections that all read (add — …).
This keeps happening. A shell function I built for capturing ideas: used twice, both on the same afternoon, never again. Seven labels in my task app that have never once touched a task.
And the corollary is the good news, which is why the fleet exists at all: every app I’ve shipped replaced exactly this kind of failed convention, and every one of them stuck. The answer to an empty folder is never “try harder to fill the folder.” It’s a surface you glance at, because a surface you glance at cannot be forgotten.
The corpse, and the small version
The strongest objection the reviewer left standing was that golf v1 already existed and had already died.
I’d asked for it on September 1st of the previous year — an ambitious spec, all the stats, “make it beautiful.” A repo appeared. It never produced a single committed build. Eight months later I found it during a project audit: 47 dirty files, last commit predating the ask itself, and I archived it.
So the right answer wasn’t the seven-component assembly. It was the smallest shippable surface: a read-only page showing short-game trends, rounds pulled from the handicap API, the practice log as a form instead of a folder, and a countdown to the fall tournament. Shippable in a weekend, because capture and data already worked.
The rule I wrote down: promote it to a real branded app only if I’m still opening it in October. Version one died of ambition. Version two started as a page over data that was already flowing.
The endpoint that quietly ignores pagination
Now the section for anyone building on someone else’s private API.
The handicap service’s login is a reverse-engineered flow against an RSA-encrypted token — I’m not publishing the recipe, and there’s a standing note in my ops doc that when logins start failing with token errors it means they’ve rotated the public key in their web bundle and I need to re-extract it. That’s the maintenance cost of building on someone’s private API, and it’s the correct price to know in advance.
The interesting bug is downstream of that. Version one pulled rounds from the obvious per-golfer scores endpoint and displayed twenty rounds. It always displayed twenty rounds. I assumed that was a limit I’d set.
It wasn’t. That endpoint silently ignores pagination parameters and always returns the current twenty-score handicap revision window. Pass it an offset, get the same twenty back, and not a peep about it. The API is behaving correctly for the handicap system’s purposes and misleadingly for mine.
A different endpoint accepts real offset pagination. Switching to it turned twenty rounds into 229, going back to 2015 — plus 80 for my son, whose scores ride on a parent account as a minor record. Suddenly there was a decade of history to aggregate instead of a rolling five-month window.
Two boundaries worth naming, because they shape everything downstream:
- Only 107 of the 229 rounds carry hole-by-hole detail. Older posted scores are just a number. Any career stat computed from fairways, greens, or putts has to say out loud which subset it’s over, or it’s lying by omission.
- The to-par field is occasionally the string
NH— not handicapped. My numeric parse fell over on it. Filter non-numerics before aggregating rather than trusting a regex.
The sync is lazy with a 12-hour cache, and if the service is unreachable it serves the stale cache behind an amber warning rather than showing nothing or, worse, showing stale numbers as if they were fresh.
Charts with no charting library
Every chart is server-rendered SVG. No client-side chart library, no hydration, no runtime dependency on a package that will have a breaking major version next year.
The trade is real and I’d make it again. I gave up interactive zoom and pan, which I have never once wanted from a scatter of my own wedge shots. I got pages that render instantly on a phone with two bars of signal, and charts that work with JavaScript disabled.
The part that makes it feel designed rather than cheap: the SVG is styled entirely with CSS custom properties, so it inherits the app’s theme instead of hardcoding colors. Flip the palette switcher and every chart restyles live, because the chart never knew its own colors in the first place — it just referenced variables that changed. It’s the same discipline as the rest of the design system, extended into the data layer.
Two details I’m pleased with:
The dark-mode accent needed its own value. The light accent sat above the lightness band that works on a dark surface — technically the same brand hue, practically illegible glowing wire. So the dark theme carries a chart-specific accent, one shade adjusted. Charts have different contrast requirements than text and pretending otherwise is how you get a beautiful palette nobody can read.
Shape carries the meaning, not just color. A filled dot means the round counts toward the handicap, or the up-and-down was made. A hollow dot means it doesn’t. That distinction survives colorblindness, grayscale printing, and a phone screen in direct Florida sunlight, because it was never encoded in hue to begin with. Accessible by construction rather than by palette audit.
The greens book I recovered from my own photo library
The best afternoon of this build was archaeology.
In November 2024 I’d screenshotted all 18 green contour maps — heat maps and putt maps — from a course app at my home club. Then they vanished into my Photos library, because a screenshot of a contour map contains essentially no text for OCR to index. They weren’t lost. They were unfindable, which is the same thing with extra steps.
What surfaced them was scanning Apple’s own on-device ML labels and detected-text index rather than filenames or captions. Eighteen images, timestamped within two minutes of each other on a single morning.
Then the verification step I’d repeat anywhere: I confirmed they were actually my course by matching all 18 pars against the hole map from the handicap API. Two independent sources agreeing beats one source asserting.
Now each green sits on the course page next to my career ledger on that hole. And the two halves of that page agree with each other in a way I can actually use: the holes bleeding the most strokes are all greens where the read amounts to “stay on the calm half,” and the two holes I play under par are the two flattest surfaces on the property. My par-3 greens-in-regulation is meaningfully worse than my overall — and three of the four most severe greens out there are par 3s. That’s not a swing problem. That’s a target-selection problem, and I only know it because two datasets from different decades ended up on the same page.
Two small traps worth passing on
The share card needed fonts inside the container. The father-son head-to-head renders as an SVG converted to PNG at 2x for texting. It kept coming out with no text at all. The renderer had no fonts — a minimal container image doesn’t ship any, and the SVG rasterizer silently draws nothing rather than erroring. Installing fontconfig and the two actual typefaces into the image fixed it. If your server-side SVG renders shapes but no words, that’s the reason.
A bare rsync exclude eats more than you think. My deploy excluded data to protect the runtime data directory. That pattern matches any path segment named data — including src/data/, where I’d just put a reference file. It silently didn’t sync and the container build failed on a missing import. Anchor the exclude to the root, or keep source-of-truth files somewhere that isn’t called data.
What I deliberately didn’t build
Tee-time booking stays a command-line tool. The scraper lives on a different machine with its own credentials, and the CLI already answers the question — cross-machine plumbing for a feature that isn’t broken is how a weekend app becomes a project. The team-coaching mode is parked until next February, because the season is.
And there’s a deliberately separate logbook for rounds I don’t post to the handicap service, which never touches the handicap or head-to-head math. It exists because I play roughly two and a half times more often than I post, and a system that pretends otherwise would be describing someone else’s golf.
The number still says what it says. But now I know which greens it comes from.