Poker Tracker
The home game’s ledger: who’s up, who’s down, and who owes who — to the cent.

Watch it run
02 TAKESEvery feature, step by step
05 FEATURES · 08 SCREENS · OPEN ONEThe standings
01 STEPThe home screen is the argument settled: everyone ranked by profit across every night, to the cent.
Logging a night
02 STEPSBuilt for eleven at night: everyone starts at the standard buy-in, a rebuy is one tap, and the table has to add up before its numbers count.
Settling up
02 STEPSWinnings say who won; payments say who has since paid. What anyone owes is both, and the plan is built from it.
Two phones, one ledger
02 STEPSEach phone keeps its own copy and works offline. Turn sharing on and the group reads and writes one ledger.
The paper trail
01 STEPEvery session, payment and roster change goes into one log, with who made it.
The trailer at the top is built motion rather than app footage, and says so; everything below it is the real app running — the same build that is live on GitHub Pages, byte for byte. Every name and number is invented, and has to be: the real ledger is my friends’ winnings and losses, kept on their phones and in their own Supabase project, none of which was opened or touched. The cast is the same invented one the Locked In and Bets captures use, with my own name the one real thing left, since it is already the title of this site. The history was not written into storage. A script typed the five earlier nights, Jonah’s correction, Marcus being added and two settle-ups into the app’s own screens on a clock wound back to each one, switching “Who’s using this phone” to stand in for each friend; tonight, and Marcus’s phone joining the group, are typed live by the captures. So every session, payment and log entry was made by the app’s own code — and the payment amounts are what the app suggested at the time. Sharing runs against a stand-in for Supabase: the three Postgres functions are answered inside the browser with the same rules the real SQL enforces, at a project URL, demo-project.supabase.co, that cannot belong to anyone, because a real project’s address is twenty lowercase letters; any request to another host is refused and fails the run. Two bugs surfaced while capturing, and both were fixed in the app and deployed before these were shot rather than framed around: an open phone with sharing on synced every 2.5 seconds for as long as it stayed open, and the Sessions list drew its cards in the browser’s default link blue. Dates in form fields read in US order because browser-drawn date controls follow the capture machine’s regional settings, not the app.
Built for the home game my friends and I play — a $10 buy-in at five- and ten-cent blinds, where the money is small and the arguments about it are not. Poker Tracker is the ledger: log a night in the time it takes to type the cash-outs, keep everyone’s running total to the cent, and settle up in the fewest payments that clear the table. It works offline on each phone, and with sharing on, the whole group reads and writes one ledger.
How it’s put together
Built with
- PERIOD
- September 2026
- ROLE
- Solo — design, build, ship
- SOURCE
- View on GitHub ↗
The hard parts
12 NOTESEvery amount is stored as integer cents and only turned into dollars on the way to the screen. Poker maths is a night of sums and differences, and floating point drifts — 0.1 + 0.2 is not 0.3 — so a float ledger at ten-cent blinds can finish a night a cent out. Display has its own rule: whole amounts show bare and anything with cents shows both digits, never one, which is what letting the formatter drop a trailing zero produces — “$13.5”, which reads as broken.
A new night opens with everyone at the standard buy-in and a one-tap + buy-in on each row, so a normal session only needs the cash-outs typed. The editor adds the table up as you go and holds its verdict until a cash-out exists — with buy-ins pre-filled, a fresh session would otherwise open showing a loss on every row and a warning that isn’t true yet. When the totals disagree it says by how much, and why that matters: chips can’t appear or vanish, so the difference is a typo, not a result. It still lets you save, and the leaderboard flags any night that doesn’t balance rather than quietly folding it in.
Settling up is the provably fewest payments, not just a short list. Balances are netted first, so nobody both pays and gets paid. Then everyone is split into as many groups as possible whose balances sum to zero, because a group of k people always needs exactly k−1 payments — so the minimum is n minus the number of groups. Paying the biggest debtor into the biggest creditor, the obvious approach, only guarantees n−1 and misses tables where a subset cancels out: on the night in the captures below it takes four payments where this takes three. Finding the best split is exponential, 3ⁿ, so it runs exactly for up to 14 people with a balance — about five million steps, held under 250ms by a performance test — and falls back to the greedy pass above that, which a home game never reaches. A property test replays 200 random tables and checks every player lands exactly on their net.
Sessions say who won; they don’t say who has since handed money over. Payments are records of their own — from, to, amount, date — and what anyone still owes is their session nets plus what they’ve paid minus what they’ve been paid. The settle-up plan is built from that rather than from raw winnings, so a debt already paid stops being suggested, part payments work, and overpaying flips who is owed instead of breaking anything. Each suggested transfer has a button that records it with the amount pre-filled.
Each phone keeps its own copy, saved on every change, and works with no signal at all; sharing is optional. With it on, a sync pulls the group’s ledger, merges it with the phone’s and pushes the result back. Every record carries a timestamp and the newer copy wins, so two people logging different nights on different phones both keep their work — only edits to the same record conflict. Deletes are tombstones with their own timestamp, so an older copy on another phone can’t bring a deleted session back. Edits are ordered by each phone’s own clock — a fair bet for one group of friends, and the limit of last-write-wins.
Settings were the one record that didn’t merge that way, and that was a bug. The merge took the server’s whole settings record whenever it had a group name — which, once sharing is on, is always — so changing the currency appeared to save, reverted two and a half seconds later exactly when the sync fired, and was pushed back to everyone. Settings now carry their own timestamp and merge like every other record, and untouched defaults are stamped zero so a phone joining a group still adopts the group’s currency, buy-in and stakes. Regression tests cover the reported case, the reverse direction, a first join and a repeated sync; the smoke test changes the currency and checks it is still there four seconds later.
Capturing this page turned up one more sync bug, in the live app. Every sync changes the ledger — the merge commits a fresh copy of it — and the engine pushed a couple of seconds after any change to the ledger, so each sync scheduled the next: an open phone pulled and pushed the entire ledger every 2.5 seconds for as long as the app stayed open, twelve round trips in thirty idle seconds. The store now counts edits made on the phone and the engine schedules on that count; a merge from the group isn’t an edit, because the sync that brought it in has already pushed it back. Taking the loop out exposed what it had been covering for — a sync asked for while another was running was dropped, so an edit made mid-sync waited for the minute timer — and that now queues one more run. The same idle phone now makes none.
The Supabase anon key is designed to ship to clients, so the table is locked rather than the key. Row-level security is on with no policies, which leaves the anonymous role unable to touch the table at all, and every read and write goes through one of three SECURITY DEFINER functions that each require the group’s password — without it you can’t read, write or even confirm a group exists. Group names are forgiving on purpose (“friday felt” finds “Friday Felt”, accents and punctuation aside), so the password is what actually protects a group, with an eight-character floor enforced in the client and again in the SQL. It is compared as typed rather than hashed — whoever created the Supabase project can read the group’s numbers anyway — which is why the setup guide says not to reuse a password you care about.
The first version baked the group’s Supabase project and join code into the build, so friends needed no setup at all. It lasted about ten minutes: the site is public on GitHub Pages, and anything in its JavaScript is readable by anyone who finds the URL. Nothing is baked in now — the project URL, key, group name and password travel together in one invite a phone’s Settings composes — and for this page both the live site’s scripts and the released APK were checked: neither contains a Supabase address or a key.
Every session, payment and roster change is written through one path that appends to an activity log, so nothing can change the numbers without leaving a trace. An edit records the nets on both sides — “Nadia: +$6.20 → -$6.10” — because a quietly rewritten bad night is the thing worth catching, and the log has an Edits & deletions filter for exactly that. The author is whoever the phone is set to in Settings, saved by name at the time so a later rename can’t rewrite history, and the screen says plainly that it is an honour-system record: it answers “who changed my numbers?”, not much more. Entries are never edited, so on a merge the log is a union rather than a contest, capped at the newest 300.
iPhones can’t sideload, so they get the installable web app — its own icon, no browser chrome, works offline — and Android gets a real APK from the same build through Capacitor. The service worker is registered by hand so the native build can skip it: inside Capacitor the files are already on the phone, and a worker’s precache there would keep serving the previous version’s screens after an update until someone cleared the app’s data. The Supabase client, about 350 kB, is only loaded once sharing is switched on.
63 unit tests pin down the money, the stats, the settle-up, the merge and the activity diffs, and a Puppeteer smoke test drives the real interface the way a person would: adds players, logs a night with a rebuy, checks the leaderboard and the settle-up agree with what was typed, records a payment, changes the currency and checks it stays changed, edits a session and reads the edit back out of the log. It compares money by sign and digits only, so it passes whatever the currency and whatever the machine’s locale.
Want the parts that aren’t on this page — the architecture arguments, the things that broke, a live walkthrough?