The Keep
Keeper, trade and live-draft system for a 16-team fantasy league. It replaced a spreadsheet that had drifted.
- Role
- Sole engineer and co-commissioner
- Year
- 2026
- Stack
- Next.js, TypeScript, Supabase Postgres, Row Level Security, Supabase Realtime, Vercel Cron, Claude API, Vitest
- manager accounts across 16 teams, in weekly useReported by Ryan · source
- 19
- SQL migrations, each a reviewed changeCounted in the repo · source
- 29
- test files over a framework-free rules engineCounted in the repo · source
- 31
The problem
A 16-team keeper league ran on a shared Google Sheet. Costs were transcribed wrong, player names had decayed, the grid could not show two picks in one round, and nothing recorded why a pick changed hands. An external data source got the keeper round wrong about half the time.
What I built
- 01Trades accepted atomically. One row-locked SECURITY DEFINER function checks that the caller is the counterpart, that every asset is still owned by the side offering it, and that neither team ends above three keepers. Only then does it move contracts and picks. A proposer can never accept his own offer, commissioners included.
- 02A keeper-cost cascade from rulebook section 4.5, written as pure functions. Tests check that the set of consumed picks is the same under all six declaration orders.
- 03A live draft board on Supabase Realtime that treats the socket as a doorbell, not a delivery: a change triggers a debounced server refresh, with a polling heartbeat underneath when the socket drops.
- 04A draft clock derived from timestamps, not a ticking value, so all sixteen screens agree.
- 05Ask the rules: retrieval over the rulebook runs first, a weak match is refused before any API call, and Claude answers only from the retrieved passages and cites the rule numbers.
- 06Team colors derived for accessibility. Each of the 16 colors is adjusted until it clears WCAG AA on both themes, and CI fails if one does not.
More screens




Decisions
Invariants in the database, not the UI
A stale page must never produce a partial trade. Moving the checks into one locked transaction made that impossible, with no commissioner exemption.
Audited the data source against the league's own contracts
The external draft data disagreed with 48 real contracts about half the time. Unverified records now carry a trust flag and never prefill a form.
Found security gaps by querying the live database
The migrations looked right, but the schema had no grants, so every Row Level Security policy was moot. Reads now require a claimed seat.
Not claimed
- Screens on this site use invented team and manager names on an in-memory copy of the database. The real league is private.
- In production, rank, ADP and projections are ESPN's numbers, not a model of mine. In these screens they are demo figures.
- The Live indicator in the draft film is stubbed. Each pick goes through the real server action.
- Ask the rules needs a server key, so it is not claimed as switched on in production.