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_cronfor 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:
- Coach-set competence rungs. Recommended in the discovery session and declined: it is work the coach does forever, and it only ever covers the people the coach taught. The project’s own stated purpose is to make the matching survive without the coach, so an option that deepens the dependency on them fails on its own terms.
- Weight against the boat’s target. The most physically accurate signal for a dinghy, and unusable: people will not post it, and a blank field reads as refusal rather than as missing data.
- The ladder as a filter. The version where a rating below the minimum removes you from the skipper’s list. Rejected because a wrong self-rating then excludes rather than warns — and the person it excludes is exactly the graduate this board exists to get onto a boat.
- Multi-club matching from day one. Deferred rather than killed. It trades the one reach channel the owner actually controls, the club’s own class roster, for a committee’s yes, and it makes the project’s central claims uncheckable without a second club to check them in.
- Availability only, with no ladder at all. The first answer given during discovery, and reversed at the narrowing gate: a board that only says who is free leaves the skipper to do the matching, which is the work that was already failing.
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.