Case study

Tender

A race-day board that pairs skippers who are short of crew with people who want to race, built around a matching engine that widens its own search until it finds somebody.

Role
Sole developer: discovery, architecture, code, and the pilot at my own club.
Stack
Next.js and TypeScript, Supabase Postgres with row-level security as the authorization, pg_cron for the ladder clock.
Timeframe
August 2026 to now. Still in progress.
Links
Source on GitHub · tender.madcowsailing.com

The problem

At Hoover Sailing Club, crewed boats — Thistles, Flying Scots, Interlakes — go short-handed or stay on the trailer most race days because a skipper and a willing crew never found each other. That is the club coach’s account rather than a measurement, and the distinction matters enough to state plainly: the club keeps no record of boats that did not sail for want of crew, so the number cannot be recovered after the fact. What would settle it is a dock tally kept through the first month of the 2027 season, and that is the test this project has agreed to be judged by.

Matching today happens on the dock or by a text from the coach. The club had already tried the obvious fix, a shared chat channel, and it died of three things — the people who needed it were never in it, nobody owned it so it went stale, and nobody could see who was still unmatched. Skippers refusing to post was explicitly not one of them, which removed the hardest design constraint before it was ever imposed. The unmatched supply is the adult Learn-to-Sail graduates who finished a course and have no route onto a race boat.

This is a two-sided board at about ten crewed boats, which is the shape most likely to die of cold start: skippers post only when desperate, graduates look only when hopeful, and at that size the two moments rarely coincide.

What I built

A race-day board that is always current without anyone curating it. Every race date of the season exists before anyone posts, so the board is never blank — only unclaimed. A skipper posts a need against a date with a boat class and the minimum competence they will take; crew keep a self-rating and a list of hulls they will sail, and mark the days they are free. A crew taps I can, the skipper accepts one, and contact details are exchanged only at that point.

The engine is a pure function — post, pool and the current time in, an open rung and a ranked list of candidates out — with no database and no clock of its own. That is what makes it testable at all, and its test was the first real test in the repo, proven able to fail by mutation before it was trusted.

Authorization is row-level security in Postgres rather than checks in the application, because the rule that matters is a property of the data: a phone number is visible to the two people in a match and to nobody else. A bypass of the app is therefore not a bypass of the rule. Every policy has a failing-then-passing case in a test harness that runs a real Postgres in CI, and that harness’s one blind spot — it grants more than the hosted platform does — is written down in the repo rather than discovered later.

The notification is the product and the board is the record. A post reaches the crew on its current rung by push and by email; the clock that widens a post runs inside the database on a schedule, because the host’s own scheduler fires once a day with an hour of slop and the engine’s boundaries are 48 and 24 hours. Sign-up is gated by an invite code checked on the server, so the pilot stays closed to one club.

A decision I’d defend

The match ladder is a search order, not a filter. It colours how far the search had to relax, and it never hides anyone.

Concretely, every crew sits on one of three rungs relative to a post: rung 1 if they will sail that class and rate at or above the skipper’s minimum, rung 2 if they clear the minimum but not the hull, rung 3 if they rate below it. The post itself is open to a rung, and it widens on emptiness or the clock, whichever comes first: down a rung when nobody available sits on or above the current one, and no later than 48 hours before the race for rung 2, 24 hours for rung 3. The colour on the board is that open rung. Rung 3 is the floor, and a post that reaches it returns whatever it has, possibly nobody.

The part I would defend hardest is what the ladder does not do. A crew on a better rung is never dropped because the post widened, and a crew on a worse one is never removed from the list — they are ranked below, in colour. That is what makes a wrong self-rating a warning rather than an exclusion, and self-ratings will be wrong: the scale is four levels a person picks for themselves, and modesty and bravado both misreport it. A filter would turn either into silence, and silence on a two-sided board is indistinguishable from nobody being there.

What it rejected, and why:

The cost is honest, and it is paid by the reader rather than the engine. A colour on a board carries meaning a first-time user has to learn, and nothing on the screen explains a rung until they ask. The email budget on the free tier is a hundred a day, which is what forces notifications to the current rung only rather than to the whole pool — so a crew two rungs down learns about a post when the ladder reaches them, not when it is posted. Both are deliberate, and both are the kind of thing that looks like an oversight from the outside.

Outcome

An invite-only pilot at one club, live at tender.madcowsailing.com. The board, the profiles, the posting and answering, the accept that forms a match and reveals contact details, the rung notifications and the clock that steps a post down are all built and deployed. The match thread, the morning-of confirm that catches a no-show, and the admin’s read of the season metric are specified and not yet shipped.

Two architectural bets were written down with the evidence that would kill them, and both were then tested rather than assumed. The database scheduler the ladder clock depends on is not documented as available on the free plan; it was enabled on the real project and watched fire every minute for nine minutes before the decision was allowed to stand. The authorization model was to be abandoned if the contact-on-match rule needed an escape hatch to express; it did not, and the policy that enforces it reads the match table directly.

There are no usage numbers on this page because there are none worth quoting. The pilot has not run a season, the board was deliberately held empty of invitations until race dates were seeded, and the failure metric — fewer than one match per race day, averaged over a season — cannot be read before there is a season to read it over. Still in progress.

Sailors at Hoover wanting the board rather than the engineering can find it at tender.madcowsailing.com.