Golf makes a large amount of data. Then golf loses almost all of it.

The handicap service knows my scores and computes one number. The scorecard holds my notes, and the scorecard is in a drawer. Every golfer owns this drawer. No golfer has ever emptied it. My memory is the third source, and my memory lies. Ask me which hole costs me strokes. I will name the hole where I made a double bogey last Tuesday. I will be confident and wrong.

The official app shows the number. It does not show the cause of the number. So I built the page that shows the cause.

The golf. today page: the countdown to the next tournament, the handicap index, the day's practice block, and trend sparklines.

The app already existed, in parts, on three machines

The app is not the unusual part. The selection method is the unusual part.

I did not select the next project by feel. I ran an evidence search across my own data trail. The sources: the archived AI transcripts, the shell history, the vault, the live task and list contents, the bookmarks, and the source code of my existing apps. Eleven sources in total.

Then a reviewer graded the result. The reviewer had one instruction: kill each candidate and check each citation. Nine candidates died. The reviewer spot-checked sixteen citations. All sixteen held.

The verdict was direct. I built a golf app by accident, across ten months.

The parts already existed. They were only scattered:

  • The ingestion was an iMessage shorthand that I invented eight days before. I text sg, then the distance, the club, the lie, the carry, the proximity, and the up-and-down result. A script on the always-on Mac parses the text.
  • The database was a JSON file with five shots in it.
  • The handicap module was a reverse-engineered API client for 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 made the decision for me. The JSON file lived inside the Docker volume of my workout app. Only sudo could read it. The workout app has its own written rule: do not log golf here, lifting only.

I built an app inside an app that rejects it, on a machine that my main agent did not index. That is not a workaround. That is an unregistered birth.

A convention does not hold behavior. A surface does.

The search found a pattern. I saw this pattern too many times to argue with it.

Ten days before, I designed a practice-log convention: one dated note per session, in a folder made for that purpose. Ten days later, the folder held zero files. The drill library next to it had four sections. Each section read (add — …).

The pattern repeats. I built a shell function to capture ideas. I used it twice, on the same afternoon, and never again. Seven labels in my task app never touched a task.

The good news follows from the same pattern, and it is the reason the app fleet exists. Every app I shipped replaced a failed convention of this type. Every app stuck. The answer to an empty folder is not more effort to fill the folder. The answer is a surface that I glance at. I cannot forget a surface that I glance at.

The dead version, and the small version

The reviewer left one strong objection standing. Golf version one already existed, and it already died.

I asked for it on September 1 of the previous year. The spec was ambitious: all the statistics, and an instruction to make it beautiful. A repo appeared. It never produced one committed build. Eight months later, I found it in a project audit. It held 47 dirty files, and its last commit was older than the request itself. I archived it.

So the correct answer was not the seven-part assembly. The correct answer was the smallest page that I can ship. The page shows the short-game trends, the rounds from the handicap API, the practice log as a form instead of a folder, and a countdown to the fall tournament. One weekend was sufficient, because the capture and the data already worked.

I wrote one rule. I will promote the page to a real branded app only if I still open it in October. Version one died of ambition. Version two started as a page above data that already flowed.

The endpoint that ignores pagination

This section is for each person who builds on a private API.

The login for the handicap service is a reverse-engineered flow with an RSA-encrypted token. I will not publish the recipe. My ops doc holds a standing note. When logins fail with token errors, the service rotated the public key in its web bundle. Then I must extract the key again. That is the maintenance cost of a private API, and it is the correct price to know in advance.

The interesting bug came after the login. Version one pulled rounds from the obvious per-golfer scores endpoint. It showed twenty rounds. It always showed twenty rounds. I thought that I set that limit.

I did not set that limit. The endpoint ignores the pagination parameters in silence. It always returns the current twenty-score handicap revision window. Send an offset. Receive the same twenty rounds and no warning. The API is correct for the handicap system. The API misled me.

A different endpoint accepts real offset pagination. I switched to it. Twenty rounds became 229 rounds, back to 2015. My son added 80 more rounds, because his scores ride on a parent account as a minor record. I now had a decade of history instead of a rolling five-month window.

Two limits shape all downstream work:

  • Only 107 of the 229 rounds carry hole-by-hole detail. An older posted score is only a number. A career statistic for fairways, greens, or putts must state its subset. A statistic without its subset lies by omission.
  • The to-par field sometimes holds the string NH, which means not handicapped. My numeric parser failed on it. Filter the non-numeric values before you aggregate. Do not trust a regex.

The sync is lazy, with a 12-hour cache. When the service is not reachable, the app serves the stale cache behind an amber warning. It does not show an empty page. It does not show stale numbers as fresh numbers.

Charts with no chart library

Each chart is server-rendered SVG. There is no client-side chart library. There is no hydration. There is no package with a breaking major version next year.

The trade is real, and I would make it again. I lost interactive zoom and pan. I never wanted zoom on a scatter plot of my own wedge shots. I got pages that render immediately on a phone with two bars of signal. The charts work with JavaScript off.

One decision makes the charts feel designed and not cheap: CSS custom properties style the SVG. The charts inherit the app theme. They do not contain color values. Change the palette, and each chart restyles immediately, because the chart never knew its own colors. It is the same discipline as the rest of the design system, applied to the data layer.

Two details please me:

The dark mode needed its own accent value. The light accent was too light for a dark surface. It was the same brand hue. It was also an unreadable glowing wire. So the dark theme carries a chart-specific accent, one shade different. Charts have different contrast needs than text. Deny that, and you get a beautiful palette that no person can read.

Shape carries the meaning, not only color. A filled dot means that the round counts for the handicap, or that the up-and-down succeeded. A hollow dot means that it does not. The difference survives colorblindness, grayscale print, and a phone screen in direct Florida sunlight. The meaning was never in the hue alone. The chart is accessible by construction, not by a palette audit.

The greens book that I recovered from my own photo library

The best afternoon of this build was archaeology.

In November 2024, I made screenshots of all 18 green contour maps from a course app at my home club. The maps showed heat and putt lines. Then the screenshots disappeared into my Photos library. A screenshot of a contour map contains almost no text, so OCR cannot index it. The images were not lost. They were not findable, which is the same thing with extra steps.

A scan of Apple’s on-device ML labels and detected-text index surfaced them. Filenames and captions did not. Eighteen images, with timestamps inside a two-minute window on one morning.

Then came a verification step that I would repeat anywhere. I matched all 18 pars against the hole map from the handicap API. Two independent sources that agree beat one source that asserts.

Each green now sits on the course page, next to my career record on that hole. The two halves of the page agree in a way that I can use. The holes that lose the most strokes are all greens where the read is “stay on the calm half.” The two holes that I play under par have the two flattest greens on the property.

My par-3 greens-in-regulation number is worse than my overall number. Three of the four most severe greens are on par 3s. That is not a swing problem. That is a target-selection problem. I know it only because two datasets from different decades landed on one page.

Two small traps to pass on

The share card needed fonts inside the container. The father-son head-to-head card renders as an SVG, then converts to a PNG at 2x for texting. The card kept coming out with no text at all. The renderer had no fonts. A minimal container image ships no fonts, and the SVG rasterizer draws nothing instead of an error.

I installed fontconfig and the two real typefaces into the image. That fixed it. If your server-side SVG shows shapes but no words, that is the cause.

A bare rsync exclude removes more than you think. My deploy excluded data to protect the runtime data directory. That pattern matches each path segment named data. It also matched src/data/, where I put a reference file. The file did not sync, in silence, and the container build failed on a missing import. Anchor the exclude to the root, or do not name a source folder data.

The parts that I did not build

Tee-time booking stays a command-line tool. The scraper lives on a different machine with its own credentials. The CLI already answers the question. Cross-machine plumbing for a feature that works turns a weekend app into a project. The team-coaching mode waits until next February, because the season waits also.

A separate logbook holds the rounds that I do not post to the handicap service. It never touches the handicap math or the head-to-head math. It exists because I play about 2.5 times more often than I post. A system that denies this would describe the golf of a different person.

The number still says what it says. Now I know which greens produce it.